Your notebook is full, @name. For half a season you wrote waterhole counts into it, then you patched the gaps, drew the charts and used tests to find out which differences were real. Yesterday a radio message came in from headquarters: build a model that predicts whether a species is endangered. You hand the notebook over exactly as it is, press enter, and get back a single line:
could not convert string to float: 'Lion'
The model cannot read the word "Lion". It cannot read "Savanna", it cannot read a date and it cannot read a sentence describing a track. A model sees numbers arranged in a table and nothing whatsoever beyond that. And the moment you do give it nothing but numbers, a second problem starts up: the count column runs from 85 to 5000, so one waterhole with a migrating herd crushes every other measurement, in exactly the way it crushed the mean in the previous lesson.
Both problems are solved by the same piece of work, and that work is what today is about.
A feature is one column of values that you hand to the model. Herd size is a feature. Territory area is a feature. Species is not one yet, because it is text, but in a moment we will turn it into three numeric features.
Feature engineering is creating new features from raw data for machine learning models. You take what you carried back from the field and rework it into columns the model can load and can actually learn something from. Sometimes you rescale an old column, sometimes you split one column into several, and sometimes - this is the best part - you invent a column that was never in the notebook at all.
Memorize that definition in precisely those words, because it is easy to confuse with three neighbouring things. Feature engineering is not removing all features from data - that would be absurd, since once every column is gone there is nothing left for a model to learn from. There is a related field called feature selection, which throws out useless columns, but it throws out some of them, never all of them, and it is a separate stage. Feature engineering is not the process of training models either - training is the next step along, the one with the
fit call, and it begins once the features are ready. And finally, feature engineering is not a data visualization method - drawing is what Matplotlib and Seaborn are for, from the lesson on charts, and not one single picture will come out of today's work, only new columns of numbers.We start from the raw scribbles. Two familiar libraries are needed: pandas under the shortcut
pd, which holds tables, and NumPy under the shortcut np, which computes on arrays. I build the data frame with the pd.DataFrame function from a dictionary, where the key is a column name and the value is a list of measurements. I call it plain df, because that is the name you will meet in the practical exercises. The notebook holds five observations and six columns: species, habitat, population, territory area in square kilometres, danger level as recorded in the field, and protection status written out in words as "Yes" and "No".1import pandas as pd
2import numpy as np
3
4df = pd.DataFrame({
5 'species': ['Lion', 'Elephant', 'Cheetah', 'Lion', 'Elephant'],
6 'habitat': ['Savanna', 'Forest', 'Savanna', 'Forest', 'Savanna'],
7 'population': [120, 450, 85, 200, 5000],
8 'area_km2': [100, 500, 50, 200, 10000],
9 'danger': ['High', 'Low', 'High', 'Medium', 'Low'],
10 'endangered': ['Yes', 'No', 'Yes', 'No', 'No']
11})
12
13print(df)
14# species habitat population area_km2 danger endangered
15# 0 Lion Savanna 120 100 High Yes
16# 1 Elephant Forest 450 500 Low No
17# 2 Cheetah Savanna 85 50 High Yes
18# 3 Lion Forest 200 200 Medium No
19# 4 Elephant Savanna 5000 10000 Low NoFive rows, six columns and not one feature more. Four of those columns are pure text, so as far as the model is concerned they do not exist. The remaining two are numbers, but on a scale that does more harm than good. The whole rest of the lesson is about turning those six columns into a dozen or so that a model will genuinely accept.
Before we add anything, one rule of the camp: you never scribble over the raw notebook. If a new feature turns out to be nonsense, you want to be able to go back to the field records without reloading the file. Making an independent copy of a frame is the job of the
copy method, called on the frame as df.copy(). It hands back a new table with the same data but living at a different address in memory, so adding a column to the copy does not touch the original. I name the copy features, because that is where everything we manufacture today will land.1features = df.copy()
2
3print(features.shape)
4# (5, 6)For now the copy is the identical twin of the original: the same five rows and six columns, as the
shape attribute confirms. Nothing was computed and nothing changed - this is simply a second copy of the notebook, one you are allowed to scrawl on. Keep the name df.copy() in mind, because a few paragraphs from now I will come back to it in connection with a certain function that beginners very often mistake for copying a frame.Look at the
population column once more: 120, 450, 85, 200 and then suddenly 5000. The largest value is almost fifty nine times the smallest. For most models that means the first four waterholes blur into one indistinguishable dot right next to zero, while the model's entire attention goes to the fifth. A distribution shaped like that is called skewed - you met it in the statistics lesson as positive skew, the long tail on the right hand side.The cure is a logarithm. A logarithm grows more and more slowly, so it squeezes big numbers harder than small ones and flattens that tail. NumPy has a ready made function called
log1p, whose name reads as "the logarithm of one plus x": it takes a number, adds one to it, and only then computes the natural logarithm. That added one is not a whim. A plain np.log of zero gives minus infinity, and a waterhole where not a single animal turned up that day is a perfectly ordinary entry in the notebook. np.log1p(0) gives a calm zero and nothing breaks.The call is made of six parts in a fixed order: first the library shortcut
np, then a dot, then the function name log1p, then an opening bracket, then the thing to be converted, which is df['population'], and last a closing bracket. The dot tells Python "reach inside NumPy", and the brackets say "do it now". The bare name np.log1p without brackets would compute nothing at all - it would only be a pointer to a function. The result of the logarithm carries a dozen or so decimal places, so for printing I attach the round method, which trims numbers to the given number of decimal places and changes nothing in the data itself.1features['log_population'] = np.log1p(df['population'])
2
3print(features[['population', 'log_population']].round(2))
4# population log_population
5# 0 120 4.80
6# 1 450 6.11
7# 2 85 4.45
8# 3 200 5.30
9# 4 5000 8.52The ratio of the largest value to the smallest dropped from 58.8 to 1.9, so the migrating herd has stopped drowning out the rest of the reserve. More importantly, the order of the rows did not change: the smallest waterhole still has the smallest value and the largest still has the largest, because a logarithm is an increasing function and never rearranges data. The
population column did not change either, and neither did anything at all in the df frame - a seventh column simply joined the old six inside features.The logarithm improved the shape of one column, but it did not solve the second complaint: population is measured in hundreds, area in thousands, and density will come out in fractions. A model that computes distances between observations will then treat the column with the bigger numbers as the more important one, even though nobody ever said so. All the features have to be brought onto a common yardstick.
The most common way is standardization, also known as the Z-score. From every value you subtract the mean of the column, and you divide the result by the standard deviation of that column. You know both of those ideas from the previous lesson: the mean is computed by the
mean method and the standard deviation by the std method, both called directly on the column. The result reads very simply: zero means exactly the mean, one means "one deviation above the mean", minus two means "two deviations below".1mean = df['population'].mean()
2std = df['population'].std()
3
4features['pop_standardized'] = (df['population'] - mean) / std
5
6print(features[['population', 'pop_standardized']].round(2))
7# population pop_standardized
8# 0 120 -0.49
9# 1 450 -0.34
10# 2 85 -0.51
11# 3 200 -0.45
12# 4 5000 1.78The four ordinary waterholes landed just below zero, and the wildebeest herd sits 1.78 deviations above the mean. Notice that the subtraction and the division worked on the whole column at once, with no loop anywhere - that is the NumPy behaviour you already know, where an arithmetic operation spills across every element. Neither the order of the rows nor their number changed in the process: standardization shifts and rescales the axis, it does not touch the data.
That the axis really did move underneath the mean can be checked by hand. The new column has, by definition, a mean of zero and a standard deviation of one, no matter what units the original numbers were measured in.
1print(round(features['pop_standardized'].mean(), 10))
2# -0.0
3print(round(features['pop_standardized'].std(), 10))
4# 1.0The mean came out as minus zero, which is ordinary floating point litter: the true result is a number of the order of ten to the minus seventeenth, and rounding to the tenth decimal place leaves nothing behind but the minus sign. The deviation came out at exactly 1.0. From now on the
pop_standardized column speaks the same language as every other standardized column in the table, and the model can compare them fairly.Standardization offers no guarantee at all about the range - values can come out at minus three and at plus ten. There are situations where you need hard bounds, for instance when you are feeding a neural network, or when you want to show the guides a progress bar running from zero to a hundred percent. That is when you reach for Min-Max normalization.
The formula has three ingredients and all of them come from the same column: from the value you subtract the column minimum, and you divide the result by the range, meaning the difference between the maximum and the minimum. We write it as
(x - min) / (max - min). The minimum is given by the min method and the maximum by the max method, both called on the column exactly the way mean was; I park them in the variables lo and hi so the formula can be read at a single glance. The effect is always the same and follows straight from the arithmetic: the smallest measurement puts a zero in the numerator, so it comes out as exactly 0.0; the largest makes the numerator equal to the denominator, so it comes out as exactly 1.0; everything else falls somewhere in between.1lo = df['population'].min()
2hi = df['population'].max()
3
4features['pop_normalized'] = (df['population'] - lo) / (hi - lo)
5
6print(features[['population', 'pop_normalized']].round(3))
7# population pop_normalized
8# 0 120 0.007
9# 1 450 0.074
10# 2 85 0.000
11# 3 200 0.023
12# 4 5000 1.000The waterhole with 85 animals got a zero, the one with 5000 got a one, and the other three squeezed into the bottom eight hundredths of the interval. And that is at the same time the biggest weakness of this method: a single outlier eats the entire scale, packing the rest of the data up against zero. Standardization, in the previous section, spread the very same numbers out far more clearly. That is why I recommend standardization as the default choice, and Min-Max normalization for when a range from zero to one is a hard requirement from whoever receives the data - and preferably after taking the logarithm of a skewed column first. What did not change here either: the order of the rows, and the original
population column, which you can still go back to.Sometimes the exact head count is not what you want to give the model at all. The reserve office is not asking whether there were 120 or 130 animals at the waterhole - it is asking whether that herd is small, medium, large or huge. Turning a number into an interval is called binning (from bin, a bucket), and in pandas it is done by the
pd.cut function.pd.cut takes three things. The first is the column to be cut. The second is bins, the list of bucket edges - give it four edges and you get three intervals, give it five and you get four. The third is labels, the list of names for those intervals, and it must be exactly one item shorter than the list of edges. I set the last edge to np.inf, NumPy's infinity, so that the top bucket will accept a herd of any size at all. The intervals are open on the left and closed on the right by default, so (0, 100] includes one hundred but not zero.1features['pop_category'] = pd.cut(
2 df['population'],
3 bins=[0, 100, 300, 1000, np.inf],
4 labels=['small', 'medium', 'large', 'huge']
5)
6
7print(features[['population', 'pop_category']])
8# population pop_category
9# 0 120 medium
10# 1 450 large
11# 2 85 small
12# 3 200 medium
13# 4 5000 hugeFive numbers turned into five labels: 85 fell into
small, 120 and 200 into medium, 450 into large, and the wildebeest herd into huge. Now for the most important sentence in this section, the one about what did not happen. Not a single row disappeared - there are still five of them. Not a single column disappeared. No copy of the frame was made. pd.cut divides numerical data into categories, that is, into bins, and adds a new column made out of them, and it leaves absolutely everything else in peace.That is worth underlining, because the word cut suggests three untrue things at once. Copying a frame is the job of
df.copy(), which you met earlier, and it was its result that we named features. Removing columns is df.drop(columns=['area_km2']) from the pandas lesson, and after it the frame really does have fewer columns. Cutting out the rows that meet a condition is a boolean mask, something written along the lines of df[df['population'] > 100], which hands back a shorter table. pd.cut does none of those three things - all it does is stick interval labels onto numbers.pd.cut slices the number line at the places you point to yourself, so the buckets can end up wildly uneven in terms of how many observations they hold - it can easily happen that one waterhole lands in huge and forty land in medium. When you want the opposite effect, buckets holding roughly the same number of observations, you reach for pd.qcut. The letter "q" stands for quantiles, which you know from the statistics lesson. Instead of edges you pass the q parameter with the number of groups: q=4 gives quartiles, q=10 gives deciles. The group names are passed the same way as in pd.cut, through labels.1features['pop_quartile'] = pd.qcut(
2 df['population'],
3 q=4,
4 labels=['Q1', 'Q2', 'Q3', 'Q4']
5)
6
7print(features[['population', 'pop_quartile']])
8# population pop_quartile
9# 0 120 Q1
10# 1 450 Q3
11# 2 85 Q1
12# 3 200 Q2
13# 4 5000 Q4The edges computed themselves, out of the data alone, and nobody had to guess where a "small" herd stops being small. Five observations cannot be split evenly into four groups, so
Q1 got two waterholes and the other groups got one each - with bigger datasets that unevenness disappears. Notice that 5000 landed in Q4 in just the same way that 450 landed in Q3: for pd.qcut the only thing that counts is your position in the queue, not the distance, which makes it a method that shrugs off outliers. The population column, once again, came through untouched.Everything we have done so far reworked columns that already existed. Now comes the finest part of feature engineering: inventing columns that nobody measured. Look at two numbers from the notebook, the population and the territory area. Each of them on its own says very little: 5000 animals sounds impressive right up until you ask how large an area they are spread across. What is genuinely interesting is the density, the number of animals per square kilometre. It comes from dividing one column by another and it is called a ratio feature.
1features['density'] = df['population'] / df['area_km2']
2
3print(features[['species', 'population', 'area_km2', 'density']])
4# species population area_km2 density
5# 0 Lion 120 100 1.2
6# 1 Elephant 450 500 0.9
7# 2 Cheetah 85 50 1.7
8# 3 Lion 200 200 1.0
9# 4 Elephant 5000 10000 0.5Read that table carefully, because it turns your conclusions upside down. The largest population, those 5000 animals that were crushing everything, has the lowest density in the whole reserve: 0.5 animals per square kilometre. The smallest population, 85 cheetahs, has the highest: 1.7. Neither of the source columns said any of this on its own - the information was sitting in the relationship between them and only came to light after the division. As a bonus, the new column has no scale problem left, because every value fits between 0.5 and 1.7. Both source columns stayed in the frame unchanged and you can go on using them.
A ratio is not the only way to mould a new feature. When you suspect that a relationship is not a straight line - because a territory twice as big does not feed twice as many animals - you add polynomial features: a column raised to a square with the
** operator, or its root through the np.sqrt function. And when you suspect that only two things taken together carry the signal, you build an interaction feature, the product of two columns.1features['area_sqrt'] = np.sqrt(df['area_km2'])
2features['pop_x_area'] = df['population'] * df['area_km2']
3
4print(features[['area_km2', 'area_sqrt', 'pop_x_area']].round(2))
5# area_km2 area_sqrt pop_x_area
6# 0 100 10.00 12000
7# 1 500 22.36 225000
8# 2 50 7.07 4250
9# 3 200 14.14 40000
10# 4 10000 100.00 50000000The square root squeezed ten thousand square kilometres down to a hundred and evened out the scale much as the logarithm did. The product went the other way and reached fifty million, so a feature like that wants standardizing before it goes anywhere near a model. And one warning from camp: given that any pair of columns can be multiplied, divided and squared, six columns easily become a hundred. Do not do it. Build the features you can justify in words, the way density was justified in the previous section - the rest is noise, and a model loses the trail in noise. The original columns, needless to say, are still lying in the frame untouched.
Back to the error from the start of the lesson. Four of the notebook's columns are text, and a model accepts nothing but numbers. They have to be encoded, and the most important encoding method is One-Hot Encoding.
The idea goes like this: every possible value of the category gets its own separate column, and in that column we write a one when the row belongs to the category and a zero when it does not. The name comes from electronics - in every row exactly one column is "hot", that is, lit, and all the others are dark. In pandas this is done by the
pd.get_dummies function, where dummy means a stand-in column. Let us start with a single column so you can see the mechanism on its own.1print(pd.get_dummies(df['species']))
2# Cheetah Elephant Lion
3# 0 False False True
4# 1 False True False
5# 2 True False False
6# 3 False False True
7# 4 False True FalseOne text column turned into three boolean columns, one per species occurring in the data. In every row exactly one value is
True - row zero is a lion, so the Lion column lit up. Pandas returns the logical values True and False here by default rather than ones and zeros, but for a model that is the same information, because True is treated as 1 and False as 0. If you would rather look at numbers, you add the dtype=int argument.1print(pd.get_dummies(df['habitat'], dtype=int))
2# Forest Savanna
3# 0 0 1
4# 1 1 0
5# 2 0 1
6# 3 1 0
7# 4 0 1Two habitats, two binary columns, one single one in every row. It is worth noticing that with only two categories the second column adds nothing new - if
Forest holds a zero then Savanna has to hold a one. For some models that redundancy is harmful, so pd.get_dummies accepts a drop_first=True argument, which removes the first column from each set.In practice you rarely encode columns one at a time.
pd.get_dummies accepts a whole frame and finds everything inside it that is text by itself. The call is made of six parts in a fixed order: the shortcut pd, a dot, the name get_dummies, an opening bracket, the frame name df, a closing bracket.1encoded = pd.get_dummies(df)
2
3print(encoded.shape)
4# (5, 12)Six columns turned into twelve while the number of rows stayed exactly the same - and that is the heart of this operation. The four text columns unfolded into ten binary ones: three species, two habitats, three danger levels and two protection statuses. The two numeric columns,
population and area_km2, went through pd.get_dummies without any change at all - the function touches categories only and leaves numbers alone.That last sentence is worth remembering, because a few stubborn misunderstandings circle around One-Hot Encoding. It is not used for normalizing numerical data - normalization is the formula
(x - min) / (max - min) you met earlier, or standardization, and both of those work on numbers; the fact that encoding produces zeros and ones is an accidental resemblance, because nobody scaled those zeros and ones, they were merely switched on. It is not used for removing outliers either - you detect extreme measurements with the interquartile range rule from the data exploration lesson, and One-Hot removes not one single row, it only adds columns. And finally it is not used for merging DataFrames - gluing tables together is what pd.merge and pd.concat from the pandas lesson are for, while pd.get_dummies works on one frame and never even sees a second one. One-Hot Encoding does one job: it converts categorical variables into binary columns.There is a shorter road to encoding the species: number them one after another. That is what
LabelEncoder from the scikit-learn library does, from the package imported as sklearn, and more precisely from its preprocessing submodule, where all the data preparation tools live. You create a LabelEncoder() object and then call the fit_transform method on it, which in one movement learns the list of categories and swaps them for numbers. Let us see what comes of it, because the result is instructive.1from sklearn.preprocessing import LabelEncoder
2
3le = LabelEncoder()
4features['species_encoded'] = le.fit_transform(df['species'])
5
6print(features[['species', 'species_encoded']])
7# species species_encoded
8# 0 Lion 2
9# 1 Elephant 1
10# 2 Cheetah 0
11# 3 Lion 2
12# 4 Elephant 1One column instead of three, so it looks economical. The trouble is that the numbers were handed out alphabetically: Cheetah got 0, Elephant 1, Lion 2. A model given a column like that will read things out of it that nobody ever wrote - that a lion is bigger than an elephant, that an elephant lies exactly halfway between a cheetah and a lion, and that the average of a cheetah and a lion is an elephant. Species have no order, so every number like that is a lie planted in the data. That is why for input columns with no natural ordering I recommend
pd.get_dummies and nothing else, and I keep LabelEncoder for encoding the column being predicted, where the model treats the values as class names rather than as quantities.There are cases, though, where the categories have an order written into their very definition. The
danger column takes the values "Low", "Medium" and "High" - and here high danger genuinely is greater than low. Categories like that are called ordinal, and assigning them increasing numbers is then an honest thing to do. You do it with a dictionary and the map method, which walks along the column and swaps every value for whatever it finds under that key in the dictionary.1danger_map = {'Low': 0, 'Medium': 1, 'High': 2}
2features['danger_level'] = df['danger'].map(danger_map)
3
4print(features[['danger', 'danger_level']])
5# danger danger_level
6# 0 High 2
7# 1 Low 0
8# 2 High 2
9# 3 Medium 1
10# 4 Low 0The order of the numbers now mirrors an order that exists in reality, so the model can honestly work out that a one step rise in danger changes something. Watch out for one trap:
map gives no warning about unknown values. If somebody had written "Extreme" into the notebook, and it is not in the dictionary, a silent missing value NaN would appear in the resulting column - which is why it is worth checking after every mapping whether any new holes have opened up. The danger column in its text form stayed in the frame untouched.That leaves the last text column:
endangered, with the words "Yes" and "No". With only two possible values you need neither one-hot encoding nor a dictionary - a comparison is enough. The expression df['endangered'] == 'Yes' checks the condition for the whole column at once and hands back a column of boolean values, exactly the same mask you used for filtering in the pandas lesson. The astype method changes the type of a column, and astype(int) turns True into 1 and False into 0.1features['endangered_binary'] = (df['endangered'] == 'Yes').astype(int)
2
3print(features[['endangered', 'endangered_binary']])
4# endangered endangered_binary
5# 0 Yes 1
6# 1 No 0
7# 2 Yes 1
8# 3 No 0
9# 4 No 0The brackets around the comparison are essential, because without them Python would first try to call
astype on the string 'Yes'. The result is exactly what One-Hot Encoding with drop_first=True would give on a two category column - one column instead of two, with no loss of information. Notice that the original column is still standing right next to it, so in the report for the office you can show a readable "Yes" while handing the model a 1.A column holding an observation date is even worse for a model than text: even if you grind it down into a number of seconds, what comes out is a counter rising forever, and you cannot read either the season or the rhythm of the week out of it. A date has to be broken into parts. Pandas keeps them under the
dt accessor, available on any column of date type. You reach for them with the form column.dt.name: dt.year is the year, dt.month is the month number, dt.quarter is the quarter, dt.dayofweek is the day of the week counted from zero for Monday to six for Sunday, and dt.dayofyear is the day number within the year. The dates themselves I load with the pd.to_datetime function, which turns strings into real dates.1dates = pd.DataFrame({
2 'observation_date': pd.to_datetime([
3 '2023-01-14', '2023-03-15', '2023-07-04', '2023-12-31'
4 ])
5})
6
7dates['year'] = dates['observation_date'].dt.year
8dates['month'] = dates['observation_date'].dt.month
9dates['day_of_week'] = dates['observation_date'].dt.dayofweek
10dates['quarter'] = dates['observation_date'].dt.quarter
11dates['day_of_year'] = dates['observation_date'].dt.dayofyear
12
13print(dates)
14# observation_date year month day_of_week quarter day_of_year
15# 0 2023-01-14 2023 1 5 1 14
16# 1 2023-03-15 2023 3 2 1 74
17# 2 2023-07-04 2023 7 1 3 185
18# 3 2023-12-31 2023 12 6 4 365One column became six, and each one carries a different kind of signal: the year catches the multi year trend, the month and the quarter catch the dry and the rainy season, the day of the week catches the working rhythm of the camp. Check it on the second row: 15 March 2023 was a Wednesday, so
day_of_week is 2, counting Monday as zero. The last row is 31 December, day 365 of the year and the fourth quarter. The observation_date column itself stayed in the table - breaking a date into parts takes nothing away from it.Out of those parts you can immediately glue together a feature more useful than any of them alone. The
isin method checks whether a value belongs to a given list and hands back a boolean mask - and five and six are precisely Saturday and Sunday. The astype(int) on the end turns the mask into zeros and ones.1dates['is_weekend'] = dates['day_of_week'].isin([5, 6]).astype(int)
2
3print(dates[['observation_date', 'day_of_week', 'is_weekend']])
4# observation_date day_of_week is_weekend
5# 0 2023-01-14 5 1
6# 1 2023-03-15 2 0
7# 2 2023-07-04 1 0
8# 3 2023-12-31 6 1Two observations fell at the weekend and got a one, two fell midweek and got a zero. It is far easier for a model to pick up one clear zero-one column than to guess that the values 5 and 6 in the
day_of_week column happen to mean something they share. The same trick works for the dry season, the migration window or the days after a downpour.There is one problem with a month written as a number from 1 to 12, and it is not obvious at first glance. As far as the model is concerned December, which is 12, lies eleven units away from January, which is 1 - further away than June is. In nature it is the other way round: one month passes between a December and a January observation and the weather is almost the same. The year is a circle, and a number from 1 to 12 is a line segment.
The answer is cyclical encoding: instead of one number you give the model two, being the position of the month on a circle. They are computed by the
np.sin and np.cos functions, and the formula is the same in both cases - you divide the month number by 12 to get the fraction of the year, and multiply by the full angle, which is two times np.pi, where pi is the constant pi as recorded in NumPy. I pack the month numbers into pd.Series, the single column structure you know from the pandas lesson, because four sample months do not need a whole table.1months = pd.Series([1, 2, 6, 12])
2
3month_sin = np.sin(2 * np.pi * months / 12)
4month_cos = np.cos(2 * np.pi * months / 12)
5
6print(pd.DataFrame({
7 'month': months,
8 'month_sin': month_sin.round(3),
9 'month_cos': month_cos.round(3)
10}))
11# month month_sin month_cos
12# 0 1 0.500 0.866
13# 1 2 0.866 0.500
14# 2 6 0.000 -1.000
15# 3 12 -0.000 1.000Now compare the pairs of points on that circle. January sits at
(0.5, 0.866), December at (0.0, 1.0), February at (0.866, 0.5). The distance from January to December is 0.518 and it is exactly the same as the distance from January to February - so the December and January pair is finally as close together as it is in reality. June, half a year away, lies 1.932 from January, on the opposite side of the circle. The minus in front of the zero in the last row is floating point litter again: the true value of the sine of a full angle is a number of the order of ten to the minus sixteenth. The same trick works on the hour of the day and on the day of the year.One thing is left in the notebook, the one least like a table: the sentences the guides pencil in beside an observation. You cannot hand them to a model directly, but you can squeeze numbers out of them. Pandas offers text operations under the
str accessor, working on the whole column at once: str.len gives the length of the string in characters, str.split breaks a sentence into a list of words, and str.contains checks whether a given fragment occurs in the text and hands back the boolean mask you already know. By attaching one more .str.len() to str.split() you get the number of words in each sentence.1notes = pd.DataFrame({
2 'description': [
3 'Large male lion spotted near river',
4 'Small elephant calf with mother',
5 'Fast cheetah hunting gazelle'
6 ]
7})
8
9notes['text_length'] = notes['description'].str.len()
10notes['word_count'] = notes['description'].str.split().str.len()
11notes['has_river'] = notes['description'].str.contains('river').astype(int)
12
13print(notes[['text_length', 'word_count', 'has_river']])
14# text_length word_count has_river
15# 0 34 6 1
16# 1 31 5 0
17# 2 28 4 0Three sentences turned into three columns of numbers, and each carries a different signal: the length of a description is often a measure of how unusual the sighting was, while
has_river is a ready made zero-one feature saying that the animal was seen near water. The description column stayed untouched and is still fit for humans to read. There are of course methods that take the entire vocabulary of a text and weigh the importance of every word - the best known is called TF-IDF - but they belong to natural language processing and carry far too many ideas of their own to fit into this lesson. For now three hand invented columns are more than enough.One warning remains, and without it all the work above can quietly cheat on you. When we computed the standardization, the mean and the deviation were computed from all five rows. If some of those rows were test data, the data the model is only going to be checked against, then information from the test would have leaked into the data preparation and the results would come out far too beautiful. That is why in real projects the transformations are described up front and only then run on the appropriate slice of data.
The tool for that is
ColumnTransformer from the compose submodule of scikit-learn: it takes a list of jobs, where each job is a triple - a name of your choosing, a transforming tool, and the list of columns it is to work on. StandardScaler is ready made standardization, the thing you computed by hand earlier, and OneHotEncoder is ready made one-hot encoding, with a drop='first' argument matching the drop_first you already know. The fit_transform method does two things at once: it learns the statistics it needs from the given data and transforms that data immediately.1from sklearn.compose import ColumnTransformer
2from sklearn.preprocessing import StandardScaler, OneHotEncoder
3
4preprocessor = ColumnTransformer([
5 ('num', StandardScaler(), ['population', 'area_km2']),
6 ('cat', OneHotEncoder(drop='first'), ['habitat'])
7])
8
9matrix = preprocessor.fit_transform(df)
10
11print(matrix.shape)
12# (5, 3)Out of the frame came a plain NumPy array with five rows and three columns: two numeric columns after standardization plus one binary column for the habitat, because with two categories and
drop='first' only one is left. The columns you did not name in any job simply did not make it into the result - ColumnTransformer passes through only what it was pointed at. The standardized values here differ from the ones we computed by hand - the wildebeest row comes out at 2.00 instead of 1.78 - because scikit-learn divides by the deviation computed for a whole population while pandas divides by the sample deviation by default. That same preprocessor can later be dropped in as the first step of a Pipeline object, which welds the data preparation and the model into one whole - and then the statistics are computed on the training data only and a leak becomes impossible.fit, later on) and it is not a data visualization method (Matplotlib and Seaborn are there for that).np, ., log1p, (, df['population'], ). The log1p variant instead of log survives zeros.(x - mean) / std and gives a mean of 0 and a deviation of 1. Min-Max normalization is (x - min) / (max - min) and forces the data into exactly the range from 0.0 to 1.0. By default I recommend standardization, because a single outlier does not eat its scale.pd.cut divides numerical data into categories, that is, into bins. It does not copy a DataFrame (that is df.copy()), it does not remove columns from a DataFrame (that is df.drop) and it does not cut the rows that meet a condition (that is a boolean mask). pd.qcut does the same job with buckets of equal counts.pd.merge and pd.concat are there for merging.pd, ., get_dummies, (, df, ). Numeric columns pass straight through it unchanged.LabelEncoder numbers categories alphabetically and talks the model into an order that does not exist. Number only genuinely ordinal categories, with a dictionary and the map method.dt accessor into year, month, quarter, day of the week and day of the year, and you rescue the cycle with the pair np.sin and np.cos.str.len, str.split and str.contains, and you compute the statistics for scaling at the very end through ColumnTransformer, so that they cannot leak in from the test data.The model will never see what you saw at the waterhole, @name - it will only see the columns you mould for it, so from today it is you who decides how much of the reserve can be read at all.