carray 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +568 -0
  3. data/README.md +6 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_descriptor.h +27 -24
  6. data/ext/ca_axis_dispatch.c +220 -0
  7. data/ext/ca_axis_group.c +201 -123
  8. data/ext/ca_binop_dispatch.c +46 -52
  9. data/ext/ca_binop_dispatch.h +1 -1
  10. data/ext/ca_categorical_iterator.c +249 -183
  11. data/ext/ca_compare.c +7 -12
  12. data/ext/ca_for_buffer.h +21 -68
  13. data/ext/ca_group_iter.c +38 -23
  14. data/ext/ca_kernel_iterator.c +1765 -2076
  15. data/ext/ca_kernel_iterator.h +584 -566
  16. data/ext/ca_monop_dispatch.c +86 -63
  17. data/ext/ca_monop_dispatch.h +5 -0
  18. data/ext/ca_obj_array.c +223 -56
  19. data/ext/ca_obj_bincmp.c +172 -138
  20. data/ext/ca_obj_binop.c +116 -230
  21. data/ext/ca_obj_bitarray.c +40 -26
  22. data/ext/ca_obj_bitfield.c +100 -68
  23. data/ext/ca_obj_block.c +19 -9
  24. data/ext/ca_obj_byte_swap.c +45 -46
  25. data/ext/ca_obj_const_string.c +269 -44
  26. data/ext/ca_obj_face.c +612 -89
  27. data/ext/ca_obj_face.h +111 -47
  28. data/ext/ca_obj_fake.c +36 -79
  29. data/ext/ca_obj_farray.c +7 -3
  30. data/ext/ca_obj_field.c +33 -8
  31. data/ext/ca_obj_fixlen_string.c +23 -24
  32. data/ext/ca_obj_grid.c +541 -210
  33. data/ext/ca_obj_meld.c +331 -111
  34. data/ext/ca_obj_moncmp.c +80 -65
  35. data/ext/ca_obj_monop.c +177 -149
  36. data/ext/ca_obj_object.c +664 -248
  37. data/ext/ca_obj_record.c +63 -23
  38. data/ext/ca_obj_reduce.c +18 -16
  39. data/ext/ca_obj_refer.c +140 -58
  40. data/ext/ca_obj_remap.c +85 -24
  41. data/ext/ca_obj_repeat.c +24 -9
  42. data/ext/ca_obj_roll.c +121 -16
  43. data/ext/ca_obj_select.c +61 -58
  44. data/ext/ca_obj_select_axis.c +73 -107
  45. data/ext/ca_obj_shift.c +32 -102
  46. data/ext/ca_obj_stack.c +372 -122
  47. data/ext/ca_obj_stride.c +348 -140
  48. data/ext/ca_obj_string.c +88 -22
  49. data/ext/ca_obj_tile.c +38 -21
  50. data/ext/ca_obj_time.c +45 -31
  51. data/ext/ca_obj_timedelta.c +43 -29
  52. data/ext/ca_obj_transpose.c +17 -6
  53. data/ext/ca_obj_triop.c +125 -141
  54. data/ext/ca_obj_window.c +330 -44
  55. data/ext/ca_op_ipower.c +24 -11
  56. data/ext/ca_op_powi.h +57 -30
  57. data/ext/ca_pow5_table.h +656 -0
  58. data/ext/ca_sort_kernels.h +70 -0
  59. data/ext/ca_sweep_engine.c +104 -81
  60. data/ext/ca_sweep_engine.h +7 -20
  61. data/ext/ca_triop_dispatch.c +4 -1
  62. data/ext/ca_triop_dispatch.h +5 -2
  63. data/ext/caframe_csv_reader.c +542 -0
  64. data/ext/carray.h +241 -92
  65. data/ext/carray_access.c +608 -276
  66. data/ext/carray_address_basis.c +494 -97
  67. data/ext/carray_attribute.c +129 -4
  68. data/ext/carray_bincount.c +39 -7
  69. data/ext/carray_broadcast.c +56 -19
  70. data/ext/carray_build_flags.h +1 -1
  71. data/ext/carray_call_cfunc.c +108 -28
  72. data/ext/carray_cast.c +1576 -223
  73. data/ext/carray_cast_func.rb +77 -24
  74. data/ext/carray_class.c +4 -3
  75. data/ext/carray_conversion.c +208 -75
  76. data/ext/carray_copy.c +82 -24
  77. data/ext/carray_core.c +646 -162
  78. data/ext/carray_count.c +171 -88
  79. data/ext/carray_element.c +94 -60
  80. data/ext/carray_factorize.c +670 -324
  81. data/ext/carray_generate.c +260 -83
  82. data/ext/carray_histogram.c +209 -106
  83. data/ext/carray_hold.c +14 -16
  84. data/ext/carray_index_classifier.c +422 -243
  85. data/ext/carray_internal.h +692 -3
  86. data/ext/carray_kernels_bincmp.c +7 -0
  87. data/ext/carray_kernels_binop.c +487 -120
  88. data/ext/carray_kernels_init.c +653 -50
  89. data/ext/carray_kernels_moncmp.c +15 -8
  90. data/ext/carray_kernels_monop.c +99 -43
  91. data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
  92. data/ext/carray_kernels_reduce_boolean.c +112 -36
  93. data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
  94. data/ext/carray_kernels_reduce_extreme.c +3032 -2892
  95. data/ext/carray_kernels_reduce_variance.c +439 -500
  96. data/ext/carray_kernels_scan.c +1097 -488
  97. data/ext/carray_kernels_search.c +3787 -2176
  98. data/ext/carray_kernels_sort.c +1200 -920
  99. data/ext/carray_kernels_triop.c +479 -7
  100. data/ext/carray_key_windows.c +216 -0
  101. data/ext/carray_lazy.c +967 -153
  102. data/ext/carray_loop.c +81 -65
  103. data/ext/carray_mask.c +910 -324
  104. data/ext/carray_math_kernel.h +1 -0
  105. data/ext/carray_median_percentile.c +299 -100
  106. data/ext/carray_memory_view.c +872 -758
  107. data/ext/carray_operator.c +1171 -364
  108. data/ext/carray_order.c +505 -133
  109. data/ext/carray_partition.c +112 -65
  110. data/ext/carray_random.c +327 -239
  111. data/ext/carray_round.c +273 -0
  112. data/ext/carray_scatter.c +346 -78
  113. data/ext/carray_segment.c +257 -0
  114. data/ext/carray_slab.c +249 -86
  115. data/ext/carray_sort.c +224 -227
  116. data/ext/carray_sort_kernel.c +8 -1
  117. data/ext/carray_struct.c +118 -39
  118. data/ext/carray_test.c +193 -69
  119. data/ext/carray_time_text.c +1182 -0
  120. data/ext/carray_undef.c +4 -0
  121. data/ext/carray_utils.c +579 -113
  122. data/ext/extconf.rb +27 -2
  123. data/ext/mk_call_cfunc.rb +19 -4
  124. data/ext/mk_pow5_table.rb +35 -0
  125. data/ext/mkkernel.rb +1202 -1014
  126. data/ext/ruby_carray.c +17 -5
  127. data/ext/version.h +4 -4
  128. data/ext/version.rb +4 -2
  129. data/lib/carray/attribute.rb +160 -17
  130. data/lib/carray/autoload_carray.rb +23 -27
  131. data/lib/carray/autoload_method_extension.rb +20 -5
  132. data/lib/carray/axis_group.rb +360 -63
  133. data/lib/carray/basics.rb +162 -28
  134. data/lib/carray/bincount_nd.rb +18 -2
  135. data/lib/carray/block_iterator.rb +231 -58
  136. data/lib/carray/boolean_reduce.rb +34 -42
  137. data/lib/carray/categorical.rb +242 -52
  138. data/lib/carray/categorical_iterator.rb +437 -827
  139. data/lib/carray/complex.rb +41 -34
  140. data/lib/carray/conditional.rb +150 -14
  141. data/lib/carray/const_string.rb +59 -4
  142. data/lib/carray/construct.rb +15 -11
  143. data/lib/carray/data_type_extension.rb +1 -0
  144. data/lib/carray/distance_limit.rb +77 -0
  145. data/lib/carray/fixlen_string.rb +7 -1
  146. data/lib/carray/frame/column_verbs.rb +70 -0
  147. data/lib/carray/frame/concat.rb +157 -6
  148. data/lib/carray/frame/convert.rb +82 -7
  149. data/lib/carray/frame/csv_parser.rb +688 -87
  150. data/lib/carray/frame/describe.rb +80 -0
  151. data/lib/carray/frame/frame.rb +369 -45
  152. data/lib/carray/frame/group.rb +631 -90
  153. data/lib/carray/frame/io.rb +506 -79
  154. data/lib/carray/frame/join.rb +411 -62
  155. data/lib/carray/frame/records.rb +116 -19
  156. data/lib/carray/frame/reshape.rb +455 -0
  157. data/lib/carray/frame/sort.rb +30 -18
  158. data/lib/carray/frame/verbs.rb +868 -61
  159. data/lib/carray/frame/windows.rb +117 -0
  160. data/lib/carray/frame.rb +4 -0
  161. data/lib/carray/fuse_source.rb +118 -2
  162. data/lib/carray/fusion.rb +257 -42
  163. data/lib/carray/histogram.rb +6 -1
  164. data/lib/carray/inspect.rb +54 -13
  165. data/lib/carray/iterator.rb +99 -9
  166. data/lib/carray/key_windows.rb +620 -0
  167. data/lib/carray/lazy.rb +216 -193
  168. data/lib/carray/mask_gap_fill.rb +112 -92
  169. data/lib/carray/math.rb +32 -19
  170. data/lib/carray/meld_reduce.rb +52 -21
  171. data/lib/carray/methods/align_addr.rb +8 -4
  172. data/lib/carray/methods/bin.rb +26 -18
  173. data/lib/carray/methods/bincount.rb +18 -2
  174. data/lib/carray/methods/bit_string.rb +15 -4
  175. data/lib/carray/methods/choose.rb +16 -3
  176. data/lib/carray/methods/composition.rb +18 -10
  177. data/lib/carray/methods/factorize.rb +2 -2
  178. data/lib/carray/methods/gather_nd.rb +25 -5
  179. data/lib/carray/methods/insert_block.rb +9 -14
  180. data/lib/carray/methods/is_between.rb +59 -0
  181. data/lib/carray/methods/is_in.rb +140 -10
  182. data/lib/carray/methods/join.rb +2 -7
  183. data/lib/carray/methods/lag.rb +49 -0
  184. data/lib/carray/methods/locate_addr.rb +182 -45
  185. data/lib/carray/methods/mode.rb +18 -1
  186. data/lib/carray/methods/nunique.rb +11 -6
  187. data/lib/carray/methods/repeat.rb +1 -20
  188. data/lib/carray/methods/resize.rb +21 -23
  189. data/lib/carray/methods/snap.rb +34 -12
  190. data/lib/carray/methods/unique.rb +11 -2
  191. data/lib/carray/methods/value_counts.rb +4 -11
  192. data/lib/carray/record_order.rb +208 -0
  193. data/lib/carray/rng.rb +3 -0
  194. data/lib/carray/runtime.rb +65 -17
  195. data/lib/carray/segment_iterator.rb +1221 -0
  196. data/lib/carray/serialize.rb +214 -30
  197. data/lib/carray/slab_iterator.rb +101 -27
  198. data/lib/carray/stack.rb +10 -4
  199. data/lib/carray/string.rb +105 -5
  200. data/lib/carray/string_operation_extension.rb +12 -4
  201. data/lib/carray/struct.rb +137 -58
  202. data/lib/carray/struct_builder.rb +124 -17
  203. data/lib/carray/time.rb +1222 -186
  204. data/lib/carray/window_iterator.rb +242 -81
  205. data/lib/carray.rb +9 -2
  206. data/yard-stubs/ca_obj_array.rb +4 -4
  207. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  208. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  209. data/yard-stubs/ca_obj_fake.rb +1 -1
  210. data/yard-stubs/ca_obj_field.rb +5 -1
  211. data/yard-stubs/ca_obj_grid.rb +21 -10
  212. data/yard-stubs/ca_obj_refer.rb +5 -4
  213. data/yard-stubs/ca_obj_roll.rb +2 -3
  214. data/yard-stubs/ca_obj_shift.rb +5 -1
  215. data/yard-stubs/ca_obj_stride.rb +10 -7
  216. data/yard-stubs/ca_obj_tile.rb +1 -1
  217. data/yard-stubs/ca_obj_transpose.rb +1 -1
  218. data/yard-stubs/ca_obj_window.rb +9 -5
  219. data/yard-stubs/carray_access.rb +11 -5
  220. data/yard-stubs/carray_attribute.rb +0 -5
  221. data/yard-stubs/carray_broadcast.rb +1 -1
  222. data/yard-stubs/carray_cast.rb +88 -43
  223. data/yard-stubs/carray_conversion.rb +15 -2
  224. data/yard-stubs/carray_copy.rb +12 -5
  225. data/yard-stubs/carray_core.rb +3 -3
  226. data/yard-stubs/carray_count.rb +9 -0
  227. data/yard-stubs/carray_element.rb +10 -4
  228. data/yard-stubs/carray_generate.rb +21 -1
  229. data/yard-stubs/carray_lazy.rb +5 -1
  230. data/yard-stubs/carray_loop.rb +4 -1
  231. data/yard-stubs/carray_mask.rb +60 -12
  232. data/yard-stubs/carray_math.rb +52 -14
  233. data/yard-stubs/carray_median_percentile.rb +15 -8
  234. data/yard-stubs/carray_memory_view.rb +40 -23
  235. data/yard-stubs/carray_order.rb +61 -29
  236. data/yard-stubs/carray_random.rb +13 -4
  237. data/yard-stubs/carray_scatter.rb +21 -9
  238. data/yard-stubs/carray_segment.rb +72 -0
  239. data/yard-stubs/carray_slab.rb +10 -2
  240. data/yard-stubs/carray_sort.rb +49 -8
  241. data/yard-stubs/carray_test.rb +15 -3
  242. data/yard-stubs/carray_utils.rb +0 -26
  243. metadata +19 -5
  244. data/ext/ca_for_each_element.h +0 -239
  245. data/ext/carray_kernels_map.c +0 -3467
  246. data/lib/carray/arrow_tensor.rb +0 -401
  247. data/lib/carray/table.rb +0 -108
@@ -9,49 +9,230 @@
9
9
  class CAFrame
10
10
  # Group rows by one or more keys. Each key is a column name (String) or an
11
11
  # external length-N CArray. Returns a GroupedFrame.
12
+ #
13
+ # With one key, a summary's index is the key's value of each group. With
14
+ # several, each key is a column of a summary, holding its part of each
15
+ # group's key, and the summary has no index; a CArray key among several
16
+ # names its column by its position, "key0", "key1", ....
12
17
  def group_by(*keys)
13
18
  raise ArgumentError, "group_by needs at least one key" if keys.empty?
19
+ CAFrame.__send__(:refuse_repeated_names, keys.grep(String), "group_by")
14
20
  cat = grouping_categorical(keys)
15
- axis = keys.size == 1 && keys.first.is_a?(String) ? keys.first : "group"
16
- GroupedFrame.new(self, cat, axis, keys.grep(String))
21
+ if keys.size == 1
22
+ axis = keys.first.is_a?(String) ? keys.first : "group"
23
+ return GroupedFrame.new(self, cat, axis, keys.grep(String), key: key_column(keys.first))
24
+ end
25
+ names = keys.each_with_index.map { |k, i| k.is_a?(String) ? k : "key#{i}" }
26
+ CAFrame.__send__(:refuse_repeated_names, names, "group_by")
27
+ GroupedFrame.new(self, cat, "group", keys.grep(String),
28
+ key_columns: names.zip(keys.map { |k| key_column(k) }).to_h)
29
+ end
30
+
31
+ # Whether each row repeats another row: a boolean column, true for the rows
32
+ # whose values in the named columns (every column when none are named)
33
+ # appear on more than one row, except the one +except:+ leaves out --
34
+ #
35
+ # except: :first every occurrence but the first (the default)
36
+ # except: :last every occurrence but the last
37
+ # except: :none every occurrence
38
+ #
39
+ # so +df[df.is_duplicate.not]+ keeps one row of each, and
40
+ # +is_duplicate(except: :none).not+ is the rows that appear only once.
41
+ # Values compare as group_by compares keys (NaN is one value, -0.0 is 0.0),
42
+ # and a row with a masked cell in those columns is never a duplicate, as a
43
+ # masked key is in no group. The index is compared only when named
44
+ # (by the row axis name, as group_by takes it).
45
+ #
46
+ # df[df.is_duplicate.not] # the rows without repeats
47
+ # df.is_duplicate("station", "time") # repeats of the pair
48
+ # df.is_duplicate(except: :none).not # the rows that appear once
49
+ #
50
+ # @param names [Array<String>] the columns to compare; default every column.
51
+ # @param except [Symbol] the occurrence left unmarked: :first, :last or :none.
52
+ # @return [CArray] boolean, one value per row.
53
+ # @raise [KeyError] for a name that is no column.
54
+ # @raise [ArgumentError] when a compared column is N-D, there is no column
55
+ # to compare, or +except:+ is not one of the three.
56
+ def is_duplicate(*names, except: :first)
57
+ unless %i[first last none].include?(except)
58
+ raise ArgumentError,
59
+ "is_duplicate: except: must be :first, :last or :none (got #{except.inspect})"
60
+ end
61
+ names = column_keys(names, "is_duplicate")
62
+ names = column_names if names.empty?
63
+ raise ArgumentError, "is_duplicate: the frame has no column to compare" if names.empty?
64
+ cols = names.map { |n| key_column(n, "is_duplicate") }
65
+ cat = CACategorical.send(:from_keys, cols)
66
+ codes = cat.codes
67
+ out =
68
+ case except
69
+ when :first then codes.mask_duplicates.is_masked
70
+ when :last then codes.flip(0).mask_duplicates.is_masked.flip(0).copy
71
+ when :none
72
+ index = codes.value.int64
73
+ index[codes.is_masked] = 0 if codes.has_mask?
74
+ sizes = cat.category_sizes
75
+ sizes.elements > 0 ? sizes[index].gt(1) : CArray.boolean(nrow) { false }
76
+ end
77
+ codes.has_mask? ? out & codes.is_not_masked : out
78
+ end
79
+
80
+ # Group rows into time bins of length +unit+ along the time column (or the
81
+ # time index) +name+, for the reductions GroupedFrame offers:
82
+ #
83
+ # df.resample("time", "1 hour").mean
84
+ # df.resample("time", "1 day", fill: true).aggregate("rain" => ["rain", :sum])
85
+ #
86
+ # The result's index is the bin labels as a CATime, in time order, and its
87
+ # row axis is named after +name+.
88
+ #
89
+ # +label: :left+ (the default) makes each bin start at its label and hold
90
+ # the times at or after it: [00:00, 01:00) is labelled 00:00. +label: :right+
91
+ # makes it end at its label and hold the times up to and including it:
92
+ # (00:00, 01:00] is labelled 01:00, the convention for a value that
93
+ # describes the hour before it. +origin:+ shifts the bins as +CATime#floor+
94
+ # does ("1 hour" with origin 00:30 gives 00:30, 01:30, ...).
95
+ #
96
+ # Without +fill:+, a bin with no rows does not appear. With +fill: true+
97
+ # every bin from the first to the last is a row, and an empty one reduces
98
+ # as an empty reduction does: UNDEF for +mean+, 0 for +count+ and +sum+.
99
+ # A row whose time is masked belongs to no bin.
100
+ #
101
+ # +by:+ (a column name or an Array of them) bins each group of rows on its
102
+ # own: a group is the rows sharing the +by:+ values and a bin. The result
103
+ # then has no index: the +by:+ columns and the bin times (named +name+)
104
+ # are its first columns, the groups in the order their +by:+ values first
105
+ # appear and the bins of each in time order. With +fill: true+ every group
106
+ # has every bin from the first to the last of the whole frame. A row whose
107
+ # +by:+ value is masked belongs to no group.
108
+ #
109
+ # df.resample("date", "1 week", by: "shop").sum
110
+ #
111
+ # @param name [String] the time column, or the index's axis name.
112
+ # @param unit [String, Symbol, CATime::Resolution] the bin length.
113
+ # @param origin [nil, Time, String, CATime::Element] where bins start.
114
+ # @param label [:left, :right] which end of a bin names it and is closed.
115
+ # @param fill [Boolean] whether bins with no rows are rows of the result.
116
+ # @param by [nil, String, Array<String>] columns whose groups are binned apart.
117
+ # @return [GroupedFrame]
118
+ # @raise [ArgumentError] when the column is not a CATime, or +label:+ is
119
+ # neither :left nor :right, or +by:+ names the time column.
120
+ def resample(name, unit, origin: nil, label: :left, fill: false, by: nil)
121
+ name = column_key(name, "resample")
122
+ time = key_column(name, "resample")
123
+ unless time.is_a?(CATime)
124
+ raise ArgumentError, "resample: #{name.inspect} is not a time column (got #{time.class})"
125
+ end
126
+ bins =
127
+ case label
128
+ when :left then time.floor(unit: unit, origin: origin)
129
+ when :right then time.ceil(unit: unit, origin: origin)
130
+ else
131
+ raise ArgumentError, "resample: label: must be :left or :right (got #{label.inspect})"
132
+ end
133
+ # key-labels: the bins are the index, given the column's attributes below.
134
+ grid = fill && bins.count_not_masked > 0 ? resample_grid(bins, unit) : bins.unique(sort: true)
135
+ return resample_by(name, time, bins, grid, by, fill) unless by.nil?
136
+ cat = CACategorical.from_codes(bins.locate_addr(grid), grid.to_a)
137
+ GroupedFrame.new(self, cat, name, @columns.key?(name) ? [name] : [],
138
+ index: CAFrame.__send__(:key_labels, time, grid))
139
+ end
140
+
141
+ # resample with by:. A group is a (by values, bin) pair, coded as
142
+ # group * bins + bin, so the codes run through the groups in the order their
143
+ # by values first appear and through each group's bins in time order.
144
+ private def resample_by(name, time, bins, grid, by, fill)
145
+ by_names = column_keys(Array(by), "resample")
146
+ raise ArgumentError, "resample: by: names no column" if by_names.empty?
147
+ if by_names.include?(name)
148
+ raise ArgumentError, "resample: by: names the time column #{name.inspect}"
149
+ end
150
+ by_cols = by_names.map { |n| key_column(n, "resample") }
151
+ gcat = CACategorical.send(:from_keys, by_cols)
152
+ ng = gcat.labels.size
153
+ nb = grid.elements
154
+ comb = gcat.codes.int64 * nb + bins.locate_addr(grid).int64
155
+ # key-labels: the used (group, bin) codes, not labels; those go through key_labels below
156
+ occupied = fill ? CArray.int64(ng * nb).seq! : comb.unique(sort: true)
157
+ codes = fill ? comb : comb.locate_addr(occupied)
158
+ g_of = nb > 0 ? occupied / nb : occupied
159
+ b_of = nb > 0 ? occupied % nb : occupied
160
+ # Each group's by values, from its first row.
161
+ order = gcat.sort_addr
162
+ first = order[CArray.segment_offsets(lengths: gcat.category_sizes)[0...ng]]
163
+ columns = {}
164
+ by_names.zip(by_cols).each do |n, col|
165
+ columns[n] = CAFrame.__send__(:key_labels, col, col.project(first))[g_of].copy
166
+ end
167
+ columns[name] = CAFrame.__send__(:key_labels, time, grid[b_of].copy)
168
+ glabels = gcat.labels
169
+ blabels = grid.to_a
170
+ labels = occupied.to_a.map do |c|
171
+ g, b = c.divmod(nb)
172
+ parts = by_names.size == 1 ? [glabels[g]] : glabels[g]
173
+ parts + [blabels[b]]
174
+ end
175
+ cat = CACategorical.from_codes(codes, labels)
176
+ key_names = by_names + (@columns.key?(name) ? [name] : [])
177
+ GroupedFrame.new(self, cat, "group", key_names, label_columns: columns)
178
+ end
179
+
180
+ # Every bin from the first occupied one to the last, stepped from the
181
+ # first bin so an origin's phase (or label: :right) is kept. A month or a
182
+ # year cannot be stepped on a finer grid, so it is stepped on the one-month
183
+ # or one-year grid and then given the bins' unit, as the labels without
184
+ # fill: are.
185
+ private def resample_grid(bins, unit)
186
+ base = CATime::Resolution.parse(unit).base
187
+ if %i[Y M].include?(base)
188
+ CArray.time_range(bins.min, bins.max, unit: base, step: unit).to_unit(bins.unit)
189
+ else
190
+ CArray.time_range(bins.min, bins.max, unit: bins.unit, step: unit)
191
+ end
17
192
  end
18
193
 
19
194
  # Number of rows currently selected — used by group per-group view-frames
20
195
  # and elsewhere; already provided by the core (attr_reader :nrow).
21
196
 
22
197
  private def grouping_categorical(keys)
23
- cols = keys.map { |k| key_column(k) }
24
- if cols.size == 1
25
- cols.first.categorize
26
- else
27
- # Composite key: one object cell per row holding the tuple of key
28
- # values, categorized by content (the codes are composed from the
29
- # per-column keys).
30
- n = nrow
31
- key = CArray.object(n) { |i| cols.map { |c| c[i] } }
32
- # One undetermined component makes the whole tuple undetermined, the same
33
- # answer a single masked key cell gets. Left as a value, the UNDEF inside
34
- # the tuple would intern as an ordinary distinct key and the row would
35
- # form a group of its own.
36
- undetermined = CArray.boolean(n) { |i| key[i].any? { |v| UNDEF.equal?(v) } }
37
- key[undetermined] = UNDEF if undetermined.any
38
- key.categorize
39
- end
40
- end
41
-
42
- private def key_column(key)
43
- case key
44
- when String
45
- self[key]
46
- when CArray
47
- unless key.shape[0] == nrow
48
- raise ArgumentError,
49
- "external group key length #{key.shape[0]} != nrow #{nrow}"
50
- end
51
- key
52
- else
53
- raise ArgumentError, "group key must be a column name or CArray (got #{key.class})"
198
+ CACategorical.send(:from_keys, keys.map { |k| key_column(k) })
199
+ end
200
+
201
+ # The column a key names, checked -- the one reading of a key every verb
202
+ # that takes one goes through (group_by, is_duplicate, sort_by_key, join,
203
+ # join_asof, align, pivot, resample, windows): a column of that name, or
204
+ # the index by the row axis name; or a CArray of one value per row. It
205
+ # gives each row one value. +verb+ is the method the caller called, for
206
+ # the messages.
207
+ private def key_column(key, verb = "group_by")
208
+ col = case key
209
+ when String
210
+ # The index names the rows, so it is a key as a column is, by the
211
+ # row axis name, as resample and pivot take it.
212
+ @columns.fetch(key) do
213
+ next @index if @index && @axis_name == key
214
+ raise KeyError, "#{verb}: no column #{key.inspect}"
215
+ end
216
+ when CArray
217
+ unless key.shape[0] == nrow
218
+ raise ArgumentError,
219
+ "#{verb}: the key has #{key.shape[0]} values for #{nrow} rows"
220
+ end
221
+ key
222
+ when Symbol
223
+ column_key(key, verb)
224
+ else
225
+ raise ArgumentError, "#{verb}: a group key is a column name or a CArray (got #{key.class})"
226
+ end
227
+ # A key gives each row one value. An N-D one would be categorized cell
228
+ # by cell, making groups of cells rather than of rows.
229
+ unless col.ndim == 1
230
+ name = key.is_a?(String) ? "#{key.inspect} " : ""
231
+ raise ArgumentError,
232
+ "#{verb}: the key #{name}has shape #{col.shape.inspect}, not one value per row; " \
233
+ "use one of its components (e.g. df[name][nil, 0])"
54
234
  end
235
+ col
55
236
  end
56
237
  end
57
238
 
@@ -63,15 +244,35 @@ end
63
244
  # aggregate -> declarative per-column reductions into a new frame
64
245
  # table { |g| }-> cross-column Ruby escape, g is a per-group view-frame
65
246
  class GroupedFrame
247
+ include CAFrame::ColumnVerbs
66
248
  # +key_names+ are the frame columns the grouping was keyed on. They become
67
249
  # the result's index, so the reduction shortcuts must not also return them as
68
250
  # reduced columns; an external CArray key contributes no name.
69
- def initialize(frame, cat, axis_name, key_names = [])
251
+ #
252
+ # The result's index is +index:+ when given (one entry per group, in code
253
+ # order); otherwise, for a single +key:+, the key's value at each group's
254
+ # first row, so it keeps the key's data type and Face; otherwise an object
255
+ # array of the labels.
256
+ #
257
+ # With several keys the result has no index: +key_columns:+ (name => the
258
+ # key, one value per row) become its first columns, each holding the key's
259
+ # value at each group's first row; +label_columns:+ (name => one value per
260
+ # group, in code order) give them directly, for groups that may have no
261
+ # rows (resample with fill: true).
262
+ #
263
+ # The grouping and the index are taken here, so a later change to the key
264
+ # does not rename the groups; the values are read when a reduction is.
265
+ def initialize(frame, cat, axis_name, key_names = [], index: nil, key: nil,
266
+ key_columns: nil, label_columns: nil)
70
267
  @frame = frame
71
268
  @cat = cat
72
269
  @axis_name = axis_name
73
270
  @key_names = key_names
74
271
  @labels = cat.labels # group values, in code order
272
+ @index = index ||
273
+ (key && CAFrame.__send__(:key_labels, key, key.project(first_rows)))
274
+ @label_columns = label_columns ||
275
+ key_columns&.transform_values { |col| CAFrame.__send__(:key_labels, col, col.project(first_rows)) }
75
276
  end
76
277
 
77
278
  # Number of groups.
@@ -86,74 +287,324 @@ class GroupedFrame
86
287
 
87
288
  # Raw exposure: the CArray group iterator for one column (memo §11.6).
88
289
  def [](name)
290
+ refuse_changed_rows("[]")
89
291
  @frame[name].group_by_category(@cat)
90
292
  end
91
293
 
92
294
  # Declarative aggregation (memo §11.6). Spec maps an output column name to
93
295
  # +[input_column, reduction]+, where reduction is a Symbol (vectorized,
94
296
  # applied through the group iterator) or a Proc (per-group custom, called
95
- # with the group's column slice).
96
- def aggregate(spec)
297
+ # with the group's column slice). A Symbol reduction takes its arguments
298
+ # after it, as in a call: +["temp", :percentile, 90.0]+, and its keywords
299
+ # last: +["temp", :mean, min_count: 6]+.
300
+ #
301
+ # With +keep_rows: true+ the rows are not collapsed: each row of the frame
302
+ # gets the result of its own group, so the result has the frame's rows and
303
+ # index, and a row whose key is masked (in no group) is UNDEF. The same
304
+ # keyword works for +table+ and the reductions below.
305
+ #
306
+ # df.group_by("station").aggregate("temp_mean" => ["temp", :mean], keep_rows: true)
307
+ def aggregate(spec = nil, keep_rows: false, **named)
308
+ refuse_changed_rows("aggregate")
309
+ spec = (spec || {}).merge(named)
97
310
  cols = {}
98
- spec.each do |out_name, (in_name, reduction)|
99
- out = out_name.to_s
311
+ spec.each do |out_name, (in_name, reduction, *args)|
312
+ out = @frame.__send__(:column_name_arg, out_name, "aggregate")
313
+ @frame.__send__(:column_key, in_name, "aggregate")
314
+ options = args.last.is_a?(Hash) ? args.pop : {}
100
315
  cols[out] =
101
316
  case reduction
102
317
  when Symbol
103
- self[in_name].public_send(reduction)
318
+ group_reduce(@frame[in_name], reduction, *args, **options)
104
319
  when Proc
320
+ unless args.empty? && options.empty?
321
+ raise ArgumentError, "aggregate: a Proc reduction takes no arguments (#{out.inspect})"
322
+ end
105
323
  per_group_column(in_name, reduction)
106
324
  else
107
325
  raise ArgumentError,
108
326
  "reduction must be a Symbol or Proc (got #{reduction.class})"
109
327
  end
110
328
  end
111
- CAFrame.new(cols, axis_name: @axis_name, index: label_index)
329
+ result_frame(cols, keep_rows)
112
330
  end
113
331
 
114
332
  # Cross-column Ruby escape (memo §11.6). The block receives a per-group
115
333
  # view-frame and returns a Hash of output-name => value; the values are
116
- # collected column-wise across groups into a new frame.
117
- def table
118
- collected = {}
119
- order = nil
334
+ # collected column-wise across groups into a new frame. +keep_rows: true+
335
+ # gives each row its group's values, as for +aggregate+.
336
+ #
337
+ # The columns are the names any group's Hash has, in the order they
338
+ # first appear; a group whose Hash lacks a name is UNDEF there, as
339
+ # +from_records+ reads a record that lacks a key.
340
+ def table(keep_rows: false)
341
+ refuse_changed_rows("table")
342
+ outs = []
120
343
  each_group_frame do |g|
121
344
  out = yield(g)
122
345
  unless out.is_a?(Hash)
123
346
  raise ArgumentError, "table block must return a Hash (got #{out.class})"
124
347
  end
125
- order ||= out.keys.map(&:to_s)
126
- out.each { |k, v| (collected[k.to_s] ||= []) << v }
348
+ out.each_key { |k| @frame.__send__(:column_name_arg, k, "table") }
349
+ outs << out
127
350
  end
351
+ names = CAFrame.__send__(:record_key_union, outs)
128
352
  cols = {}
129
- (order || []).each do |name|
130
- vals = collected[name]
131
- cols[name] = CArray.object(vals.size) { |i| vals[i] }
353
+ names.each do |name|
354
+ cols[name] = column_of_values(outs.map { |out| out.fetch(name, nil) })
132
355
  end
133
- CAFrame.new(cols, axis_name: @axis_name, index: label_index)
356
+ result_frame(cols, keep_rows)
134
357
  end
135
358
 
136
- # Convenience reductions over every numeric scalar column (memo §6-4
137
- # "grp.mean"). Non-numeric / N-D columns are skipped.
359
+ # Convenience reductions over every column that defines the reduction. A
360
+ # column is in the result when its type answers the reduction -- a time
361
+ # column has a mean, a minimum and a maximum but no sum, a string column a
362
+ # minimum and a maximum only -- and left out when it does not, as are the
363
+ # key columns. An N-D column is reduced along the rows, so each group keeps
364
+ # its trailing shape: a (N, L) column gives (groups, L). Each
365
+ # takes +min_count:+ and +fill_value:+ as a core reduction does: a group
366
+ # with fewer than min_count present values is UNDEF, and fill_value fills
367
+ # the UNDEF cells. +keep_rows: true+ gives each row its group's value
368
+ # instead of one row per group, as for +aggregate+.
138
369
  #
139
- # @!method sum
140
- # Returns a frame of the per-group sum of every numeric one-dimensional
141
- # column. Non-numeric and multi-dimensional columns are left out.
142
- # @return [CAFrame] one row per group, indexed by the group labels.
143
- # @!method mean
144
- # Returns a frame of the per-group arithmetic mean of every numeric
145
- # one-dimensional column, as {#sum} does.
370
+ # Given column names, they reduce those columns only and return them as
371
+ # +df[...]+ does: one name gives the CArray, several an Array of them.
372
+ #
373
+ # grp.mean # a frame, one row per group
374
+ # grp.mean("temp") # CArray, one value per group
375
+ # df["temp"] - grp.mean("temp", keep_rows: true) # anomaly from the group mean
376
+ #
377
+ # @!method sum(*names, min_count: nil, fill_value: nil, keep_rows: false)
378
+ # Returns a frame of the per-group sum of every column whose type has a
379
+ # sum, an N-D one per group along the rows. Other columns are left out.
380
+ # @return [CAFrame, CArray, Array<CArray>] a frame with one row per
381
+ # group, indexed by the group labels, or with keep_rows: true the
382
+ # frame's rows and index; given names, the CArray (one) or the Array of
383
+ # them (several).
384
+ # @!method mean(*names, min_count: nil, fill_value: nil, keep_rows: false)
385
+ # Returns a frame of the per-group mean of every column whose type has
386
+ # a mean, as {#sum} does.
387
+ # @return [CAFrame] one row per group.
388
+ # @!method min(*names, min_count: nil, fill_value: nil, keep_rows: false)
389
+ # Returns a frame of the per-group minimum of every column whose type has
390
+ # a minimum, as {#sum} does.
146
391
  # @return [CAFrame] one row per group.
147
- # @!method min
148
- # Returns a frame of the per-group minimum of every numeric
149
- # one-dimensional column, as {#sum} does.
392
+ # @!method max(*names, min_count: nil, fill_value: nil, keep_rows: false)
393
+ # Returns a frame of the per-group maximum of every column whose type has
394
+ # a maximum, as {#sum} does.
150
395
  # @return [CAFrame] one row per group.
151
- # @!method max
152
- # Returns a frame of the per-group maximum of every numeric
153
- # one-dimensional column, as {#sum} does.
396
+ # @!method prod(*names, min_count: nil, fill_value: nil, keep_rows: false)
397
+ # Returns the per-group product, as {#sum} does.
154
398
  # @return [CAFrame] one row per group.
155
- [:sum, :mean, :min, :max].each do |red|
156
- define_method(red) { reduce_numeric(red) }
399
+ # @!method accumulate(*names, min_count: nil, fill_value: nil, keep_rows: false)
400
+ # Returns the per-group sum kept in each column's own data type, as
401
+ # +CArray#accumulate+, as {#sum} does.
402
+ # @return [CAFrame] one row per group.
403
+ # @!method median(*names, min_count: nil, fill_value: nil, keep_rows: false)
404
+ # Returns the per-group median, as {#sum} does.
405
+ # @return [CAFrame] one row per group.
406
+ # @!method variance(*names, min_count: nil, fill_value: nil, keep_rows: false)
407
+ # Returns the per-group sample variance (divisor n - 1), as {#sum} does.
408
+ # @return [CAFrame] one row per group.
409
+ # @!method stddev(*names, min_count: nil, fill_value: nil, keep_rows: false)
410
+ # Returns the per-group sample standard deviation, as {#sum} does.
411
+ # @return [CAFrame] one row per group.
412
+ # @!method variancep(*names, min_count: nil, fill_value: nil, keep_rows: false)
413
+ # Returns the per-group population variance (divisor n), as {#sum} does.
414
+ # @return [CAFrame] one row per group.
415
+ # @!method stddevp(*names, min_count: nil, fill_value: nil, keep_rows: false)
416
+ # Returns the per-group population standard deviation, as {#sum} does.
417
+ # @return [CAFrame] one row per group.
418
+ CArray.send(:define_named_methods, self,
419
+ [:sum, :mean, :min, :max, :prod, :accumulate, :median,
420
+ :variance, :stddev, :variancep, :stddevp], :group_reduction)
421
+
422
+ private def group_reduction (red, *names, min_count: nil, fill_value: nil, keep_rows: false)
423
+ refuse_changed_rows(red)
424
+ reduce_columns(red, names, keep_rows, min_count: min_count, fill_value: fill_value)
425
+ end
426
+
427
+ # The same convenience for the reductions that take no min_count: or
428
+ # fill_value:, as the core ones take none: a value of the group (first,
429
+ # last), a count (nunique, count) or a boolean fold (all, any).
430
+ #
431
+ # @!method first(*names, keep_rows: false)
432
+ # Returns each group's first present value in row order, UNDEF for a
433
+ # group with none, as {#sum} returns its sums.
434
+ # @return [CAFrame] one row per group.
435
+ # @!method last(*names, keep_rows: false)
436
+ # Returns each group's last present value in row order.
437
+ # @return [CAFrame] one row per group.
438
+ # @!method nunique(*names, keep_rows: false)
439
+ # Returns each group's number of distinct present values (int64).
440
+ # @return [CAFrame] one row per group.
441
+ # @!method count(*names, keep_rows: false)
442
+ # Returns each group's number of present values (int64), a masked cell
443
+ # left out.
444
+ # @return [CAFrame] one row per group.
445
+ # @!method all(*names, keep_rows: false)
446
+ # Returns whether every present value of the group is true, for the
447
+ # boolean columns.
448
+ # @return [CAFrame] one row per group.
449
+ # @!method any(*names, keep_rows: false)
450
+ # Returns whether any present value of the group is true, for the
451
+ # boolean columns.
452
+ # @return [CAFrame] one row per group.
453
+ CArray.send(:define_named_methods, self,
454
+ [:first, :last, :nunique, :count, :all, :any], :group_value)
455
+
456
+ private def group_value (red, *names, keep_rows: false)
457
+ refuse_changed_rows(red)
458
+ reduce_columns(red, names, keep_rows)
459
+ end
460
+
461
+ # Values computed within each group and kept on the frame's rows, in row
462
+ # order: the value some rows back in the same group, a rank among the
463
+ # group's rows, a running statistic. A row in no group is UNDEF. Without
464
+ # names they give a frame of every column whose type has the operation
465
+ # (the key columns left out), on the frame's rows and index; given names,
466
+ # the CArray (one) or the Array of them (several), as +df[...]+ returns
467
+ # columns. An N-D column runs along the rows.
468
+ #
469
+ # df["dtemp"] = df["temp"] - grp.lag("temp") # change since the station's previous row
470
+ # grp.order("temp", descending: true) # rank within the station
471
+ # grp.cumsum("rain") # running total per station
472
+ #
473
+ # "Previous" is the previous row of the frame: sort the frame first
474
+ # (+sort_by_key("time")+) when its rows are not in time order.
475
+ #
476
+ # @!method lag(*names, n: 1)
477
+ # Returns each row's value +n+ rows back in its own group, UNDEF where
478
+ # the group has none, as +CArray#lag+; a negative +n+ looks ahead.
479
+ # @return [CAFrame, CArray, Array<CArray>]
480
+ # @!method order(*names, descending: false, method: :ordinal, kind: :quick)
481
+ # Returns each row's rank among its group's rows (int64, 0 the
482
+ # smallest), as +CArray#order+ ranks them.
483
+ # @return [CAFrame, CArray, Array<CArray>]
484
+ # @!method cumsum(*names)
485
+ # Returns the running sum within each group, as +CArray#cumsum+.
486
+ # @return [CAFrame, CArray, Array<CArray>]
487
+ # @!method cumprod(*names)
488
+ # Returns the running product within each group.
489
+ # @return [CAFrame, CArray, Array<CArray>]
490
+ # @!method cummax(*names)
491
+ # Returns the running maximum within each group.
492
+ # @return [CAFrame, CArray, Array<CArray>]
493
+ # @!method cummin(*names)
494
+ # Returns the running minimum within each group.
495
+ # @return [CAFrame, CArray, Array<CArray>]
496
+ # @!method cumcount(*names)
497
+ # Returns the running count of present values within each group (int64).
498
+ # @return [CAFrame, CArray, Array<CArray>]
499
+ def lag (*names, n: 1)
500
+ transform_columns(:lag, names, [n], {})
501
+ end
502
+
503
+ def order (*names, descending: false, method: :ordinal, kind: :quick)
504
+ transform_columns(:order, names, [], { descending: descending, method: method, kind: kind })
505
+ end
506
+
507
+ CArray.send(:define_named_methods, self,
508
+ [:cumsum, :cumprod, :cummax, :cummin, :cumcount], :group_scan)
509
+
510
+ private def group_scan (op, *names)
511
+ transform_columns(op, names, [], {})
512
+ end
513
+
514
+ # Fill the masked cells of a column from the rows of the same group, in
515
+ # place, as +CAFrame#fill+ does for the whole column: +:ffill+ carries the
516
+ # group's last present value forward in row order, +:bfill+ its next one
517
+ # back. A fill never reaches across into another group, so a cell with
518
+ # nothing to carry in its group stays masked, and a row whose key is
519
+ # masked (in no group) is left as it is. A constant fills every masked
520
+ # cell, as +CAFrame#fill+ does. Returns self.
521
+ #
522
+ # df.group_by("station").fill("temp", :ffill)
523
+ #
524
+ # Sort the frame first (+sort_by_key("time")+) when its rows are not in
525
+ # time order.
526
+ #
527
+ # @param name [String] the column.
528
+ # @param method_or_value [Symbol, Object] +:ffill+, +:bfill+, or a constant.
529
+ # @return [GroupedFrame] self
530
+ # @raise [ArgumentError] for +:linear+, which within groups is not
531
+ # provided, or another Symbol.
532
+ def fill (name, method_or_value)
533
+ refuse_changed_rows("fill")
534
+ case method_or_value
535
+ when :ffill, :bfill
536
+ @frame.__send__(:refuse_if_frozen)
537
+ key = @frame.__send__(:column_key, name, "fill")
538
+ col = @frame[key]
539
+ # A row in no group is UNDEF in the fill, so writing it changes nothing.
540
+ held = group_transform(col, :strip_mask, [], { method: method_or_value })
541
+ target = col.is_masked
542
+ col[target] = held[target] if target.any
543
+ when :linear
544
+ raise ArgumentError,
545
+ "fill(#{name.inspect}, :linear): an interpolation within each group " \
546
+ "is not provided; use :ffill or :bfill, or interpolate one group at a " \
547
+ "time with CAFrame#fill"
548
+ when Symbol
549
+ raise ArgumentError,
550
+ "fill: unknown method #{method_or_value.inspect} " \
551
+ "(:ffill | :bfill, or a constant fill value)"
552
+ else
553
+ @frame.fill(name, method_or_value)
554
+ end
555
+ self
556
+ end
557
+
558
+ # Keep the rows of the groups the block chooses. The block receives the
559
+ # grouped frame and returns a boolean CArray with one value per group,
560
+ # built from the group reductions, as +CAFrame#filter+'s block returns one
561
+ # per row:
562
+ #
563
+ # grp.filter { |g| g["temp"].count_not_masked >= 20 } # stations with 20 readings
564
+ # grp.filter { |g| g.mean("temp") > 15.0 }
565
+ #
566
+ # The rows of a chosen group are kept in the frame's order, as a sub-frame
567
+ # sharing the frame's columns. A group whose value is UNDEF is
568
+ # undetermined, as is a row whose key is masked (it belongs to no group):
569
+ # such rows are dropped, or with +keep_masked: true+ carried with their
570
+ # cells masked, as +CAFrame#filter+ does.
571
+ #
572
+ # @param keep_masked [Boolean] carry the undetermined rows forward masked.
573
+ # @yieldparam grouped [GroupedFrame] self.
574
+ # @yieldreturn [CArray] boolean, one value per group.
575
+ # @return [CAFrame]
576
+ # @raise [ArgumentError] when the block does not return a boolean CArray of
577
+ # shape [ngroup].
578
+ def filter (keep_masked: false)
579
+ refuse_changed_rows("filter")
580
+ chosen = yield(self)
581
+ unless chosen.is_a?(CArray) && chosen.data_type == :boolean
582
+ raise ArgumentError,
583
+ "filter block must return a boolean CArray (got #{chosen.is_a?(CArray) ? "a #{CArray::Inspector.type_label(chosen)} CArray" : chosen.class})"
584
+ end
585
+ unless chosen.shape == [ngroup]
586
+ raise ArgumentError,
587
+ "filter mask has shape #{chosen.shape.inspect}, " \
588
+ "expected [#{ngroup}] (one value per group)"
589
+ end
590
+ rows = spread_to_rows(chosen)
591
+ @frame.filter(keep_masked: keep_masked) { rows }
592
+ end
593
+
594
+ # Returns the grouped rows as a frame: the frame the grouping was made
595
+ # from, its rows, columns and index, as a new frame sharing its columns.
596
+ # Nothing is copied, so a write to a cell reaches the grouped frame's
597
+ # rows; adding or dropping a column changes the new frame only.
598
+ #
599
+ # def warm_stations (grp)
600
+ # grp.filter { |g| g.mean("temp") > 15.0 } # the rows of some groups
601
+ # end
602
+ # grp.to_frame # all of its rows
603
+ #
604
+ # @return [CAFrame]
605
+ def to_frame
606
+ refuse_changed_rows("to_frame")
607
+ @frame.__send__(:rebuild, @frame.column_names.to_h { |name| [name, @frame[name]] })
157
608
  end
158
609
 
159
610
  # @return [String]
@@ -161,32 +612,106 @@ class GroupedFrame
161
612
  "#<GroupedFrame ngroup=#{ngroup} by=#{@axis_name.inspect}>"
162
613
  end
163
614
 
615
+ # A within-group transform of the named columns, or of every column whose
616
+ # type has it. The arguments are checked by the core first: below, a
617
+ # refusal reads as "this type has no such operation", and a wrong keyword
618
+ # would otherwise leave every column out without a word.
619
+ private def transform_columns(op, names, args, keywords)
620
+ refuse_changed_rows(op)
621
+ CArray.float64(1).public_send(op, *args, **keywords)
622
+ out = over_named_columns(op, names, @key_names, args, keywords) do |col|
623
+ group_transform(col, op, args, keywords)
624
+ end
625
+ return out unless names.empty?
626
+ CAFrame.new(out, axis_name: @frame.axis_name, index: @frame.index)
627
+ end
628
+
629
+ # A column's within-group transform: an N-D column runs along the rows.
630
+ private def group_transform(col, op, args, keywords)
631
+ it = col.group_by_category(@cat)
632
+ keywords = keywords.merge(axis: 0) if col.ndim > 1
633
+ it.public_send(op, *args, **keywords)
634
+ end
635
+
636
+ # Each result gets its own index.
164
637
  private def label_index
638
+ return @index.copy if @index
165
639
  CArray.object(@labels.size) { |i| @labels[i] }
166
640
  end
167
641
 
168
- NON_NUMERIC = [:object, :boolean, :fixlen].freeze
169
- private_constant :NON_NUMERIC
642
+ # A key column is the index here, not a result column. Named columns
643
+ # come back as df[...] returns them, each on the frame's rows with
644
+ # +keep_rows+.
645
+ private def reduce_columns(reduction, names, keep_rows, **keywords)
646
+ keywords = keywords.compact
647
+ out = over_named_columns(reduction, names, @key_names) do |col|
648
+ col = group_reduce(col, reduction, **keywords)
649
+ keep_rows && !names.empty? ? spread_to_rows(col) : col
650
+ end
651
+ return out unless names.empty?
652
+ result_frame(out, keep_rows)
653
+ end
170
654
 
171
- private def reduce_numeric(reduction)
172
- cols = {}
173
- @frame.variable_names.each do |name|
174
- # A key column is the index here, not a result column. Filtering it out
175
- # by name rather than by data type is what makes a numeric key behave
176
- # like a string one: NON_NUMERIC is about which columns a reduction can
177
- # apply to, which happened to cover string keys and nothing else.
178
- next if @key_names.include?(name)
179
- col = @frame[name]
180
- next unless col.ndim == 1 && !NON_NUMERIC.include?(col.data_type)
181
- cols[name] = col.group_by_category(@cat).public_send(reduction)
655
+ # One row per group, indexed by the labels; or, with +keep_rows+, the
656
+ # frame's rows, each holding its group's result.
657
+ private def result_frame(cols, keep_rows)
658
+ unless keep_rows
659
+ return CAFrame.new(cols, axis_name: @axis_name, index: label_index) unless @label_columns
660
+ clash = cols.keys & @label_columns.keys
661
+ unless clash.empty?
662
+ raise ArgumentError,
663
+ "a result column has the name of a key column: #{clash.map(&:inspect).join(', ')}"
664
+ end
665
+ return CAFrame.new(@label_columns.transform_values(&:copy).merge(cols))
666
+ end
667
+ CAFrame.new(cols.transform_values { |col| spread_to_rows(col) },
668
+ axis_name: @frame.axis_name, index: @frame.index)
669
+ end
670
+
671
+ # A per-group result (one entry per group along axis 0) laid back over the
672
+ # frame's rows through the group codes. A row in no group is UNDEF.
673
+ private def spread_to_rows(result)
674
+ # A row in no group has a masked index cell, which x[i] answers as a
675
+ # hole: so even with no group at all, the result keeps its type.
676
+ codes = group_codes
677
+ index = codes.value.int64
678
+ index[codes.is_masked] = UNDEF
679
+ result[index, *[nil] * (result.ndim - 1)].copy
680
+ end
681
+
682
+ # A column's group reduction. An N-D column is reduced along the rows, so
683
+ # each group keeps the trailing shape: a profile column (N, L) gives (G, L).
684
+ private def group_reduce(col, reduction, *args, **keywords)
685
+ it = col.group_by_category(@cat)
686
+ if col.ndim > 1 && !keywords.key?(:axis)
687
+ it.public_send(reduction, *args, axis: 0, **keywords)
688
+ else
689
+ it.public_send(reduction, *args, **keywords)
182
690
  end
183
- CAFrame.new(cols, axis_name: @axis_name, index: label_index)
184
691
  end
185
692
 
186
693
  private def per_group_column(in_name, proc)
187
694
  results = []
188
695
  each_group_slice(in_name) { |slice| results << proc.call(slice) }
189
- CArray.object(results.size) { |i| results[i] }
696
+ column_of_values(results)
697
+ end
698
+
699
+ # The grouping holds one code per row of the frame as it was when grouped.
700
+ # A frame whose row count changed since (a splice, a deletion) has rows
701
+ # the grouping does not know, so every verb refuses it.
702
+ private def refuse_changed_rows(verb)
703
+ return if @frame.nrow == @cat.elements
704
+ raise ArgumentError,
705
+ "#{verb}: the frame has #{@frame.nrow} rows, #{@cat.elements} when it " \
706
+ "was grouped; group it again"
707
+ end
708
+
709
+ # A column of the Ruby values the groups gave, typed as from_records types
710
+ # a record's values: Integers int64, numbers float64, Strings text, time
711
+ # elements a time column, arrays of one shape an N-D column, anything else
712
+ # object. UNDEF and nil are missing.
713
+ private def column_of_values(values)
714
+ CAFrame.__send__(:build_record_column, values, values.size)
190
715
  end
191
716
 
192
717
  private def each_group_slice(in_name)
@@ -200,20 +725,36 @@ class GroupedFrame
200
725
  ngroup.times { |k| yield @frame[group_address(k)] }
201
726
  end
202
727
 
728
+ # The first row of each group, in code order.
729
+ # With few groups each row's number is folded into its group's slot by the
730
+ # smallest, which needs no sort of the rows (a numeric column then reduces
731
+ # without one too); a row in no group (masked code) is in no slot, and a
732
+ # group without rows keeps no row (UNDEF). With more groups the columns
733
+ # are gathered through the sort anyway, so the first rows are read off it.
734
+ private def first_rows
735
+ if ngroup > CACategoricalIterator::SCATTER_CATEGORIES_MAX
736
+ return group_perm[group_bounds[0...ngroup]]
737
+ end
738
+ n = @cat.elements
739
+ first = CArray.int64(ngroup).fill(n)
740
+ first.scatter_min!(@cat.codes, CArray.int64(n).seq!)
741
+ first[first.eq(n)] = UNDEF if ngroup > 0
742
+ first
743
+ end
744
+
203
745
  private def group_address(k)
204
- start = group_offsets[k]
205
- group_perm[start...(start + group_sizes[k])]
746
+ group_perm[group_bounds[k]...group_bounds[k + 1]]
206
747
  end
207
748
 
208
- private def group_perm
209
- @group_perm ||= @cat.sort_addr
749
+ private def group_codes
750
+ @cat.codes
210
751
  end
211
752
 
212
- private def group_offsets
213
- @group_offsets ||= @cat.reduceat_index
753
+ private def group_perm
754
+ @group_perm ||= @cat.sort_addr
214
755
  end
215
756
 
216
- private def group_sizes
217
- @group_sizes ||= @cat.category_sizes
757
+ private def group_bounds
758
+ @group_bounds ||= CArray.segment_offsets(lengths: @cat.category_sizes)
218
759
  end
219
760
  end