Skip to main content

polars_core/chunked_array/logical/
categorical.rs

1use std::hash::BuildHasher;
2use std::marker::PhantomData;
3
4use num_traits::Zero;
5use polars_arrow::array::DictionaryKey;
6use polars_arrow::bitmap::{Bitmap, BitmapBuilder, MutableBitmap};
7use polars_compute::cast::cast_unchecked;
8use polars_utils::hashing::{_boost_hash_combine, folded_multiply};
9
10use crate::chunked_array::cast::CastOptions;
11use crate::chunked_array::flags::StatisticsFlags;
12use crate::chunked_array::iterator::PolarsIterator;
13use crate::chunked_array::ops::ChunkFullNull;
14use crate::hashing::get_null_hash_value;
15use crate::prelude::*;
16use crate::series::IsSorted;
17use crate::utils::handle_casting_failures;
18
19pub type CategoricalChunked<T> = Logical<T, <T as PolarsCategoricalType>::PolarsPhysical>;
20pub type Categorical8Chunked = CategoricalChunked<Categorical8Type>;
21pub type Categorical16Chunked = CategoricalChunked<Categorical16Type>;
22pub type Categorical32Chunked = CategoricalChunked<Categorical32Type>;
23
24pub trait CategoricalPhysicalDtypeExt {
25    fn dtype(&self) -> DataType;
26}
27
28impl CategoricalPhysicalDtypeExt for CategoricalPhysical {
29    fn dtype(&self) -> DataType {
30        match self {
31            Self::U8 => DataType::UInt8,
32            Self::U16 => DataType::UInt16,
33            Self::U32 => DataType::UInt32,
34        }
35    }
36}
37
38impl<T: PolarsCategoricalType> CategoricalChunked<T> {
39    pub fn is_enum(&self) -> bool {
40        matches!(self.dtype(), DataType::Enum(_, _))
41    }
42
43    pub(crate) fn get_flags(&self) -> StatisticsFlags {
44        // If we use lexical ordering then physical sortedness does not imply
45        // our sortedness.
46        let mut flags = self.phys.get_flags();
47        if self.uses_lexical_ordering() {
48            flags.set_sorted(IsSorted::Not);
49        }
50        flags
51    }
52
53    /// Set flags for the ChunkedArray.
54    pub(crate) fn set_flags(&mut self, mut flags: StatisticsFlags) {
55        // We should not set the sorted flag if we are sorting in lexical order.
56        if self.uses_lexical_ordering() {
57            flags.set_sorted(IsSorted::Not)
58        }
59        self.physical_mut().set_flags(flags)
60    }
61
62    /// Return whether or not the [`CategoricalChunked`] uses the lexical order
63    /// of the string values when sorting.
64    pub fn uses_lexical_ordering(&self) -> bool {
65        !self.is_enum()
66    }
67
68    pub fn full_null_with_dtype(name: PlSmallStr, length: usize, dtype: DataType) -> Self {
69        let phys =
70            ChunkedArray::<<T as PolarsCategoricalType>::PolarsPhysical>::full_null(name, length);
71        unsafe { Self::from_cats_and_dtype_unchecked(phys, dtype) }
72    }
73
74    /// Create a [`CategoricalChunked`] from a physical array and dtype.
75    ///
76    /// Checks that all the category ids are valid, mapping invalid ones to nulls.
77    pub fn from_cats_and_dtype(
78        mut cat_ids: ChunkedArray<T::PolarsPhysical>,
79        dtype: DataType,
80    ) -> Self {
81        let (DataType::Enum(_, mapping) | DataType::Categorical(_, mapping)) = &dtype else {
82            panic!("from_cats_and_dtype called on non-categorical type")
83        };
84        assert!(dtype.cat_physical().ok() == Some(T::physical()));
85
86        unsafe {
87            let mut invariants_violated = false;
88            let mut validity = BitmapBuilder::new();
89            for arr in cat_ids.downcast_iter_mut() {
90                validity.reserve(arr.len());
91                if arr.has_nulls() {
92                    for opt_cat_id in arr.iter() {
93                        if let Some(cat_id) = opt_cat_id {
94                            validity.push_unchecked(mapping.cat_to_str(cat_id.as_cat()).is_some());
95                        } else {
96                            validity.push_unchecked(false);
97                        }
98                    }
99                } else {
100                    for cat_id in arr.values_iter() {
101                        validity.push_unchecked(mapping.cat_to_str(cat_id.as_cat()).is_some());
102                    }
103                }
104
105                if arr.null_count() != validity.unset_bits() {
106                    invariants_violated = true;
107                    arr.set_validity(core::mem::take(&mut validity).into_opt_validity());
108                } else {
109                    validity.clear();
110                }
111            }
112
113            if invariants_violated {
114                cat_ids.set_flags(StatisticsFlags::empty());
115                cat_ids.compute_len();
116            }
117        }
118
119        Self {
120            phys: cat_ids,
121            dtype,
122            _phantom: PhantomData,
123        }
124    }
125
126    /// Create a [`CategoricalChunked`] from a physical array and dtype.
127    ///
128    /// # Safety
129    /// It's not checked that the indices are in-bounds or that the dtype is correct.
130    pub unsafe fn from_cats_and_dtype_unchecked(
131        cat_ids: ChunkedArray<T::PolarsPhysical>,
132        dtype: DataType,
133    ) -> Self {
134        debug_assert!(dtype.cat_physical().ok() == Some(T::physical()));
135
136        Self {
137            phys: cat_ids,
138            dtype,
139            _phantom: PhantomData,
140        }
141    }
142
143    /// Get a reference to the mapping of categorical types to the string values.
144    pub fn get_mapping(&self) -> &Arc<CategoricalMapping> {
145        let (DataType::Categorical(_, mapping) | DataType::Enum(_, mapping)) = self.dtype() else {
146            unreachable!()
147        };
148        mapping
149    }
150
151    /// Create an [`Iterator`] that iterates over the `&str` values of the [`CategoricalChunked`].
152    pub fn iter_str(&self) -> impl PolarsIterator<Item = Option<&str>> {
153        let mapping = self.get_mapping();
154        self.phys
155            .iter()
156            .map(|cat| unsafe { Some(mapping.cat_to_str_unchecked(cat?.as_cat())) })
157    }
158
159    /// Converts from strings to this CategoricalChunked.
160    ///
161    /// If this dtype is an Enum any non-existing strings get mapped to null.
162    pub fn from_str_iter<'a, I: IntoIterator<Item = Option<&'a str>>>(
163        name: PlSmallStr,
164        dtype: DataType,
165        strings: I,
166    ) -> PolarsResult<Self> {
167        let strings = strings.into_iter();
168
169        let hint = strings.size_hint().0;
170        let mut cat_ids = Vec::with_capacity(hint);
171        let mut validity = BitmapBuilder::with_capacity(hint);
172
173        match &dtype {
174            DataType::Categorical(cats, mapping) => {
175                assert!(cats.physical() == T::physical());
176                for opt_s in strings {
177                    cat_ids.push(if let Some(s) = opt_s {
178                        T::Native::from_cat(mapping.insert_cat(s)?)
179                    } else {
180                        T::Native::zero()
181                    });
182                    validity.push(opt_s.is_some());
183                }
184            },
185            DataType::Enum(fcats, mapping) => {
186                assert!(fcats.physical() == T::physical());
187                for opt_s in strings {
188                    cat_ids.push(if let Some(cat) = opt_s.and_then(|s| mapping.get_cat(s)) {
189                        validity.push(true);
190                        T::Native::from_cat(cat)
191                    } else {
192                        validity.push(false);
193                        T::Native::zero()
194                    });
195                }
196            },
197            _ => panic!("from_strings_and_dtype_strict called on non-categorical type"),
198        }
199
200        let arr = <T::PolarsPhysical as PolarsDataType>::Array::from_vec(cat_ids)
201            .with_validity(validity.into_opt_validity());
202        let phys = ChunkedArray::<T::PolarsPhysical>::with_chunk(name, arr);
203        Ok(unsafe { Self::from_cats_and_dtype_unchecked(phys, dtype) })
204    }
205
206    /// Converts a dictionary array of strings to this CategoricalChunked.
207    ///
208    /// Gives the same result as [`Self::from_str_iter`] over the strings of `arr`. When there
209    /// are at least as many keys as values, each used value is looked up only once.
210    pub fn from_str_dictionary<K: DictionaryKey>(
211        name: PlSmallStr,
212        dtype: DataType,
213        arr: &DictionaryArray<K>,
214    ) -> PolarsResult<Self> {
215        let (DataType::Enum(_, mapping) | DataType::Categorical(_, mapping)) = &dtype else {
216            panic!("from_str_dictionary called on non-categorical type")
217        };
218        assert!(dtype.cat_physical().ok() == Some(T::physical()));
219        let is_enum = dtype.is_enum();
220
221        let values = cast_unchecked(&**arr.values(), &ArrowDataType::Utf8View)?;
222        let values = values.as_any().downcast_ref::<Utf8ViewArray>().unwrap();
223        let keys = arr.keys();
224
225        // Keys from FFI are not validated: negative keys map to null, keys out of bounds panic.
226        let per_row = || {
227            let strings = keys.iter().map(|k| values.get((*k?).try_into().ok()?));
228            Self::from_str_iter(name.clone(), dtype.clone(), strings)
229        };
230
231        // With more dictionary values than rows, a lookup per row is cheaper.
232        if values.len() > keys.len() {
233            return per_row();
234        }
235
236        // Values are looked up in order of first use, so new categories are inserted in the
237        // same order as with a lookup per row.
238        let mut seen = MutableBitmap::from_len_zeroed(values.len());
239        let mut value_cats = vec![T::Native::zero(); values.len()];
240        // Only allocated once a used value maps to null.
241        let mut value_is_valid: Option<MutableBitmap> = None;
242        let mut lookup = |k: usize| -> PolarsResult<()> {
243            let opt_cat = match values.get(k) {
244                Some(s) if is_enum => mapping.get_cat(s).map(T::Native::from_cat),
245                Some(s) => Some(T::Native::from_cat(mapping.insert_cat(s)?)),
246                None => None,
247            };
248            match opt_cat {
249                Some(cat) => value_cats[k] = cat,
250                None => {
251                    value_is_valid
252                        .get_or_insert_with(|| MutableBitmap::from_len_set(values.len()))
253                        .set(k, false);
254                },
255            }
256            Ok(())
257        };
258        for k in keys.iter().flatten() {
259            let Ok(k) = (*k).try_into() else {
260                return per_row();
261            };
262            if k >= values.len() {
263                return per_row();
264            }
265            // SAFETY: k is in bounds.
266            if !unsafe { seen.get_unchecked(k) } {
267                unsafe { seen.set_unchecked(k, true) };
268                lookup(k)?;
269            }
270        }
271
272        // SAFETY: all non-null keys were checked to be in bounds above.
273        let cat_ids: Vec<T::Native> = if keys.null_count() == 0 {
274            keys.values()
275                .iter()
276                .map(|k| unsafe { *value_cats.get_unchecked(k.as_usize()) })
277                .collect_trusted()
278        } else {
279            arr.keys_iter()
280                .map(|k| {
281                    k.map_or(T::Native::zero(), |k| unsafe {
282                        *value_cats.get_unchecked(k)
283                    })
284                })
285                .collect_trusted()
286        };
287        let validity = if let Some(value_is_valid) = value_is_valid {
288            let validity: Bitmap = arr
289                .keys_iter()
290                .map(|k| k.is_some_and(|k| unsafe { value_is_valid.get_unchecked(k) }))
291                .collect_trusted();
292            Some(validity)
293        } else {
294            keys.validity().cloned()
295        };
296
297        let arr =
298            <T::PolarsPhysical as PolarsDataType>::Array::from_vec(cat_ids).with_validity(validity);
299        let phys = ChunkedArray::<T::PolarsPhysical>::with_chunk(name, arr);
300        Ok(unsafe { Self::from_cats_and_dtype_unchecked(phys, dtype) })
301    }
302
303    pub fn to_arrow(&self, compat_level: CompatLevel) -> DictionaryArray<T::Native> {
304        let keys = self.physical().rechunk();
305        let keys = keys.downcast_as_array();
306        let values = self
307            .get_mapping()
308            .to_arrow(compat_level.uses_binview_types());
309        let values_dtype = Box::new(values.dtype().clone());
310        let dtype = ArrowDataType::Dictionary(
311            <T::Native as DictionaryKey>::KEY_TYPE,
312            values_dtype,
313            self.is_enum(),
314        );
315        unsafe { DictionaryArray::try_new_unchecked(dtype, keys.clone(), values).unwrap() }
316    }
317}
318
319impl<T: PolarsCategoricalType> LogicalType for CategoricalChunked<T> {
320    fn dtype(&self) -> &DataType {
321        &self.dtype
322    }
323
324    fn get_any_value(&self, i: usize) -> PolarsResult<AnyValue<'_>> {
325        polars_ensure!(i < self.len(), oob = i, self.len());
326        Ok(unsafe { self.get_any_value_unchecked(i) })
327    }
328
329    unsafe fn get_any_value_unchecked(&self, i: usize) -> AnyValue<'_> {
330        match self.phys.get_unchecked(i) {
331            Some(i) => match &self.dtype {
332                DataType::Enum(_, mapping) => AnyValue::Enum(i.as_cat(), mapping),
333                DataType::Categorical(_, mapping) => AnyValue::Categorical(i.as_cat(), mapping),
334                _ => unreachable!(),
335            },
336            None => AnyValue::Null,
337        }
338    }
339
340    fn cast_with_options(&self, dtype: &DataType, options: CastOptions) -> PolarsResult<Series> {
341        if &self.dtype == dtype {
342            return Ok(self.clone().into_series());
343        }
344
345        match dtype {
346            DataType::String => {
347                let mapping = self.get_mapping();
348
349                // TODO @ cat-rework:, if len >= mapping.upper_bound(), cast categories to ViewArray, then construct array of Views.
350
351                let mut builder = StringChunkedBuilder::new(self.phys.name().clone(), self.len());
352                let to_str = |cat_id: CatSize| unsafe { mapping.cat_to_str_unchecked(cat_id) };
353                if !self.phys.has_nulls() {
354                    for cat_id in self.phys.into_no_null_iter() {
355                        builder.append_value(to_str(cat_id.as_cat()));
356                    }
357                } else {
358                    for opt_cat_id in self.phys.iter() {
359                        let opt_cat_id: Option<_> = opt_cat_id;
360                        builder.append_option(opt_cat_id.map(|c| to_str(c.as_cat())));
361                    }
362                }
363
364                let ca = builder.finish();
365                Ok(ca.into_series())
366            },
367
368            DataType::Enum(fcats, _mapping) => {
369                // TODO @ cat-rework: if len >= self.mapping().upper_bound(), remap categories then index into array.
370                let ret = with_match_categorical_physical_type!(fcats.physical(), |$C| {
371                    CategoricalChunked::<$C>::from_str_iter(
372                        self.name().clone(),
373                        dtype.clone(),
374                        self.iter_str()
375                    )?.into_series()
376                });
377
378                if options.is_strict() && self.null_count() != ret.null_count() {
379                    handle_casting_failures(&self.clone().into_series(), &ret)?;
380                }
381
382                Ok(ret)
383            },
384
385            DataType::Categorical(cats, _mapping) => {
386                // TODO @ cat-rework: if len >= self.mapping().upper_bound(), remap categories then index into array.
387                Ok(
388                    with_match_categorical_physical_type!(cats.physical(), |$C| {
389                        CategoricalChunked::<$C>::from_str_iter(
390                            self.name().clone(),
391                            dtype.clone(),
392                            self.iter_str()
393                        )?.into_series()
394                    }),
395                )
396            },
397
398            dt if dt.is_integer() => {
399                polars_bail!(
400                    ComputeError: "cannot cast categorical types to {dtype:?}.\n\
401                    Instead of `.cast({dtype:?})`, use `.cat.physical()`.",
402                );
403            },
404
405            _ => polars_bail!(ComputeError: "cannot cast categorical types to {dtype:?}"),
406        }
407    }
408}
409
410impl<T: PolarsCategoricalType> VecHash for CategoricalChunked<T>
411where
412    ChunkedArray<<T as PolarsCategoricalType>::PolarsPhysical>: VecHash,
413{
414    fn vec_hash(
415        &self,
416        random_state: PlSeedableRandomStateQuality,
417        buf: &mut Vec<u64>,
418    ) -> PolarsResult<()> {
419        if self.is_enum() {
420            self.phys.vec_hash(random_state, buf)
421        } else {
422            buf.clear();
423            buf.reserve(self.phys.len());
424            let mult = random_state.hash_one(0);
425            let null = get_null_hash_value(&random_state);
426
427            let mapping = self.get_mapping();
428            for opt_cat in self.phys.iter() {
429                if let Some(cat) = opt_cat {
430                    let base_h = unsafe { mapping.cat_to_hash_unchecked(cat.as_cat()) };
431                    buf.push(folded_multiply(base_h, mult));
432                } else {
433                    buf.push(null);
434                }
435            }
436            Ok(())
437        }
438    }
439
440    fn vec_hash_combine(
441        &self,
442        random_state: PlSeedableRandomStateQuality,
443        hashes: &mut [u64],
444    ) -> PolarsResult<()> {
445        if self.is_enum() {
446            self.phys.vec_hash_combine(random_state, hashes)
447        } else {
448            let mult = random_state.hash_one(0);
449            let null = get_null_hash_value(&random_state);
450
451            let mapping = self.get_mapping();
452            assert!(self.phys.len() == hashes.len());
453            for (opt_cat, h) in self.phys.iter().zip(hashes.iter_mut()) {
454                let our_h = if let Some(cat) = opt_cat {
455                    let base_h = unsafe { mapping.cat_to_hash_unchecked(cat.as_cat()) };
456                    folded_multiply(base_h, mult)
457                } else {
458                    null
459                };
460                *h = _boost_hash_combine(our_h, *h);
461            }
462            Ok(())
463        }
464    }
465}