carray 3.0.3 → 3.0.5

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