Have you ever chained a Pandas method onto a result and been told it isn't Pandas anymore?
quakes = pd.DataFrame({'mag': [4.5, 4.5, 5.1, 4.5, 6.0],
'place': ['Chile', 'Japan', 'Chile', 'Alaska', 'Japan']})
quakes['mag'].unique().value_counts()
AttributeError: 'numpy.ndarray' object has no attribute 'value_counts'
The short answer is that somewhere in your chain, a method returned a NumPy array rather than a Pandas series. NumPy arrays don't have value_counts, dropna, str, dt, or most of the other methods you're used to, so the chain breaks at the next step.
The fix depends on what you were trying to do, and in the case above, the fix is to delete .unique().
You probably wanted value_counts on its own
value_counts already works out the distinct values, and then counts how often each appears:
quakes['mag'].value_counts()
mag
4.5 3
5.1 1
6.0 1
Name: count, dtype: int64
Calling unique() first doesn't just break things on a numeric column, it defeats the purpose. After unique(), every value appears exactly once, so even when the chain does run, every count comes back as 1. If you wanted the number of distinct values rather than their frequencies, that is nunique():
quakes['mag'].nunique()
3
Why the same line works on text
Here is the part that makes this error confusing in Pandas 3. Run the identical chain on the place column, and nothing goes wrong:
quakes['place'].unique().value_counts()
Chile 1
Japan 1
Alaska 1
Name: count, dtype: int64
The reason is what unique() returns. For a numeric column, it's a plain NumPy array:
type(quakes['mag'].unique())
<class 'numpy.ndarray'>
For a string column in Pandas 3, it is a Pandas extension array, which does have a value_counts method:
type(quakes['place'].unique())
<class 'pandas.arrays.ArrowStringArray'>
In Pandas 2, both came back as NumPy arrays, and both lines failed. So if your code used to fail on everything and now fails only on numbers, that's why. It also means the chain "working" on text isn't good news. Look at those counts again: all ones.
Three other ways to end up with an array
.values and .to_numpy() exist precisely to give you NumPy arrays, so anything chained after them loses Pandas methods:
quakes['mag'].to_numpy().dropna()
AttributeError: 'numpy.ndarray' object has no attribute 'dropna'
The fix is to do the Pandas work first and convert last, if you need to convert at all: quakes['mag'].dropna().to_numpy().
The most common source, though, is np.where, which people reach for to label rows:
np.where(quakes['mag'] >= 5, 'strong', 'moderate').value_counts()
AttributeError: 'numpy.ndarray' object has no attribute 'value_counts'
NumPy has no idea that its inputs came from a series, so it returns a bare array with no index. You can wrap the result back up with pd.Series(..., index=quakes.index), but it's usually cleaner to stay in Pandas from the start:
quakes['mag'].ge(5).map({True: 'strong', False: 'moderate'}).value_counts()
mag
moderate 3
strong 2
Name: count, dtype: int64
Or, since this is really binning a number into ranges, use pd.cut, which scales to more than two labels:
pd.cut(quakes['mag'], bins=[0, 5, 10],
labels=['moderate', 'strong'], right=False).value_counts()
mag
moderate 3
strong 2
Name: count, dtype: int64
If you wanted the unique values as a series
Sometimes you really do want the distinct values, and then want to keep working with them in Pandas. Use drop_duplicates(), which returns a series and keeps the original index:
quakes['mag'].drop_duplicates()
0 4.5
2 5.1
4 6.0
Name: mag, dtype: float64
Practice
See value_counts in Pandas, unique in Pandas, and nunique in Pandas for all three on real data. Then try the value_counts and unique exercises.