One call does most of the work. Look closely at the two ties, and at what the index is.
import pandas as pd answers = pd.Series(['b', 'a', 'b', 'a', 'c']) answers.value_counts() # b 2 # a 2 # c 1 sorted by count, descending — and b before a, because b # was met first. Not alphabetical, and not a promise. answers.value_counts(normalize=True) # b 0.4 # a 0.4 # c 0.2 the same thing as proportions of the total # The labels are the *index* of the result, not a column: counts = answers.value_counts() list(counts.index) # -> ['b', 'a', 'c'] counts['a'] # -> 2 # And the two-step sort that pins the tie order down: counts.sort_index().sort_values(ascending=False, kind='stable') # a 2 # b 2 # c 1 alphabetical within each count
- value_counts sorts by count, and leaves ties to chance
Two labels with the same count come out in whatever order the counting happened to produce, which depends on the data rather than on any rule. So a report built straight off value_counts is stable until the day two options draw level, and then it reorders itself for no visible reason. If the order matters to a reader, it has to be stated.
- Sort by the tiebreaker first, then by the thing you actually want
counts.sort_index() puts the labels in alphabetical order; sort_values(ascending=False, kind='stable') then reorders by count while leaving equal counts in the order they were already in. Sorting least significant key first and relying on stability is the general trick for ordering by several keys — the same reason kind='stable' exists at all.
- The labels are the index, which is why to_dict and .index both come up
value_counts returns a Series whose index is the distinct values and whose values are the counts. That is convenient for a lookup (counts['a']) and slightly awkward for a report, which wants the label as a field — hence .items(), or .reset_index(), or a comprehension over .index. All three are fine; knowing that the label lives in the index is what stops the confusion.
answers.value_counts() already gives you the counts in descending order. What it does not give you is a rule for the ties, or the share — and the labels are in the index rather than in a column.
Sort the counts alphabetically first, then re-sort by count descending with kind='stable'. The stable sort leaves equal counts in the alphabetical order you just put them in, which is the tiebreak the contract asks for.
For the share, divide each count by len(answers) rather than calling value_counts a second time with normalize=True — one pass over the data, one obvious denominator, and no risk of the two calls ordering their rows differently. Then iterate with .items(), which hands you the label and the count together.
import pandas as pd def label_share(answers): counts = answers.____() counts = counts.sort_index().sort_values(ascending=____, kind='____') total = len(answers) return [ {'label': label, 'count': ____(count), 'share': round(count / total, 4)} for label, count in counts.____() ]
import pandas as pd
def label_share(answers):
counts = answers.value_counts()
# Alphabetical first, then by count descending with a stable sort: equal
# counts keep the alphabetical order, which is the documented tiebreak.
counts = counts.sort_index().sort_values(ascending=False, kind="stable")
total = len(answers)
report = []
for label, count in counts.items():
# int() once, and then the division is ordinary Python arithmetic:
# an int over an int gives a plain float, not a numpy one.
count = int(count)
report.append(
{
"label": label,
"count": count,
"share": round(count / total, 4),
}
)
return report
This is how you sort by several keys when the tool only takes one at a time: order by the tiebreaker, then by the primary key with a stable sort, which leaves the earlier ordering intact wherever the primary key is equal. It generalises to any number of keys, in reverse order of importance, and it is worth recognising because you will meet it in shell pipelines and SQL-free reports for the rest of your life.
value_counts(normalize=True) would also give the shares, at the cost of a second pass over the data and a second Series to keep aligned with the first. len(answers) is the same number, available for free, and it makes the division visible on the line where it happens — which matters when somebody later asks whether the share is of all rows or only of the answered ones.
Series.items() already hands back plain Python scalars, so the int() here changes nothing today — it documents the boundary and survives a rewrite. What does leak numpy’s int64 is .values, .iloc and .at, which hand you the stored value untouched; and an int64 compares equal to an int, prints like an int, and raises TypeError the first time json.dumps sees one. Converting before the division matters for the same reason: int64 / int gives a float64, and round() on a float64 gives another float64.
If the column has missing values, this counts around them: value_counts drops NaN by default, so the shares would be of the answered rows while len(answers) counts all of them — and the shares would quietly sum to less than one. Decide which you mean and say it: value_counts(dropna=False) to make 'no answer' a row in the report, or answers.dropna() as the denominator to make the shares proportions of the answered. The bug is having one of each. And if the output is heading straight into another frame rather than to a caller who wants dicts, stop at the sort — reset_index() gives you a two-column frame and the list of dicts is a round trip you did not need.
- Add a cumulative share, so a reader can see how many labels cover 90% of the answers.
- Take a top_n argument and roll everything past it into a single 'other' row whose count and share still make the totals reconcile.
- Handle a Series with missing values, and make the choice from 'when not to' an argument.