daru_lite 0.3.3 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c158868ddcdfad0c071d5ad8b583605e86cdd3a005714df2071d0fc122e88278
4
- data.tar.gz: 56d1feb6d2b95f6a4c897589c56c32e8890fe72c9eb2e72d6b71a91ffc9e95ae
3
+ metadata.gz: f09114a32c50e38bfbf8d224919b444a820db2998e46443aa89a999fa856f8f5
4
+ data.tar.gz: 0c27255f631de63d92d01b576cf42e3880e7453e8d20edfcda3969cea6f32ce3
5
5
  SHA512:
6
- metadata.gz: 5ac43007818f83995648bdbc65c5debc0d8999c9623271f0d6ca7c70bccc99904b74ec48fd99860a38ad6d02f99493fecdffb6560cbf92fd6a6682eb1190c0a5
7
- data.tar.gz: 3f980b1dd60846f9aba374663e34fbd0118396079e1366f7e0046c288087adbf0f7f606853dec915dd9b42109f30e2f25033ea277533a711f5be8ab2948e58f3
6
+ metadata.gz: f9afdb5835447f5de2af1e7dba83592a8fb5dc4c6981f388609b3408386d03de69ad7a3382e53830d0636d522c462a2b43e988dd38336b67531d8a3ac2e4041a
7
+ data.tar.gz: a47dfc18efa0b88bd6eaf1179b0cb9d6edf9e054124ee6606f857885862af7c2324c06d23dff4e8b585ff9084c394c8e5d1fb9c29a5f3a3e7b73374888ecac8a
@@ -10,7 +10,13 @@ module DaruLite
10
10
  # * +vectors_to_dup+ - An Array specifying the names of Vectors to
11
11
  # be duplicated. Will duplicate the entire DataFrame if not specified.
12
12
  def dup(vectors_to_dup = nil)
13
- vectors_to_dup ||= @vectors.to_a
13
+ # No argument: copy by position so a MultiIndex with duplicate tuples (which cannot be
14
+ # re-looked-up unambiguously by name) is preserved intact.
15
+ if vectors_to_dup.nil?
16
+ return DaruLite::DataFrame.new(
17
+ @data.map(&:dup), order: @vectors.dup, index: @index.dup, name: @name, clone: true
18
+ )
19
+ end
14
20
 
15
21
  src = vectors_to_dup.map { |vec| @data[@vectors.pos(vec)].dup }
16
22
  new_order = DaruLite::Index.new(vectors_to_dup)
@@ -226,7 +226,7 @@ module DaruLite
226
226
 
227
227
  return @data[pos] if pos.is_a?(Integer)
228
228
 
229
- new_vectors = pos.map { |tuple| @data[@vectors[tuple]] }
229
+ new_vectors = @vectors.positions_for(names.flatten).map { |position| @data[position] }
230
230
 
231
231
  pos = pos.drop_left_level(names.size) if names.size < @vectors.width
232
232
 
@@ -25,8 +25,11 @@ module DaruLite
25
25
  def each_vector_with_index
26
26
  return to_enum(:each_vector_with_index) unless block_given?
27
27
 
28
- @vectors.each do |vector|
29
- yield @data[@vectors[vector]], vector
28
+ # Snapshot the data/name pairing up front so the block may safely mutate the DataFrame
29
+ # (e.g. add vectors) mid-iteration, and pair by position so duplicate MultiIndex tuples
30
+ # don't trigger an ambiguous name lookup.
31
+ @data.zip(@vectors.to_a).each do |vector, vector_index| # rubocop:disable Style/ExplicitBlockArgument -- preserve 2-arg yield semantics
32
+ yield vector, vector_index
30
33
  end
31
34
 
32
35
  self
@@ -158,18 +158,26 @@ module DaruLite
158
158
 
159
159
  private
160
160
 
161
+ # Resolve a sort-key name to a single integer position. A MultiIndex with duplicate tuples
162
+ # maps a name to several positions; sorting by such a key uses the first match.
163
+ def sort_vector_position(name)
164
+ @vectors.is_a?(MultiIndex) ? @vectors.positions_for(name).first : @vectors[name]
165
+ end
166
+
161
167
  def convert_categorical_vectors(names)
162
168
  names.filter_map do |n|
163
- next unless self[n].category?
169
+ position = sort_vector_position(n)
170
+ vector = @data[position]
171
+ next unless vector.category?
164
172
 
165
- old = [n, self[n]]
166
- self[n] = DaruLite::Vector.new(self[n].to_ints)
173
+ old = [position, vector]
174
+ @data[position] = DaruLite::Vector.new(vector.to_ints)
167
175
  old
168
176
  end
169
177
  end
170
178
 
171
179
  def restore_categorical_vectors(old)
172
- old.each { |name, vector| self[name] = vector }
180
+ old.each { |position, vector| @data[position] = vector }
173
181
  end
174
182
 
175
183
  def sort_build_row(vector_locs, by_blocks, ascending, handle_nils, r1, r2) # rubocop:disable Metrics/ParameterLists
@@ -223,7 +231,7 @@ module DaruLite
223
231
  handle_nils = sort_coerce_boolean opts, :handle_nils, false, vector_order.size
224
232
 
225
233
  by_blocks = vector_order.map { |v| (opts[:by] || {})[v] }
226
- vector_locs = vector_order.map { |v| @vectors[v] }
234
+ vector_locs = vector_order.map { |v| sort_vector_position(v) }
227
235
 
228
236
  lambda do |index1, index2|
229
237
  # Build left and right array to compare two rows
@@ -267,8 +267,12 @@ module DaruLite
267
267
  def delete_vector(vector)
268
268
  raise IndexError, "Vector #{vector} does not exist." unless @vectors.include?(vector)
269
269
 
270
- @data.delete_at @vectors[vector]
271
- @vectors = DaruLite::Index.new @vectors.to_a - [vector]
270
+ # Delete the first matching column by position: a MultiIndex with duplicate tuples resolves a
271
+ # name to several positions, and removing every duplicate name would desync @vectors from @data.
272
+ # Reuse the index's own #delete_at, which preserves the index type.
273
+ position = @vectors.is_a?(MultiIndex) ? @vectors.positions_for(vector).first : @vectors[vector]
274
+ @data.delete_at(position)
275
+ @vectors = @vectors.delete_at(position)
272
276
 
273
277
  self
274
278
  end
@@ -469,7 +473,9 @@ module DaruLite
469
473
  @size == other.size &&
470
474
  @index == other.index &&
471
475
  @vectors == other.vectors &&
472
- @vectors.to_a.all? { |v| self[v] == other[v] }
476
+ # Compare columns by position: name-based access is ambiguous (and recurses infinitely)
477
+ # when the vectors are a MultiIndex with duplicate tuples.
478
+ @data == other.data
473
479
  end
474
480
 
475
481
  # Converts the specified non category type vectors to category type vectors
@@ -589,7 +595,7 @@ module DaruLite
589
595
  end
590
596
 
591
597
  if pos.is_a?(DaruLite::Index)
592
- assign_multiple_vectors pos, v
598
+ assign_multiple_vectors name, pos, v
593
599
  elsif pos == name &&
594
600
  (@vectors.include?(name) || (pos.is_a?(Integer) && pos < @data.size))
595
601
 
@@ -599,10 +605,14 @@ module DaruLite
599
605
  end
600
606
  end
601
607
 
602
- def assign_multiple_vectors(pos, v)
603
- pos.each do |p|
604
- @data[@vectors[p]] = v
605
- end
608
+ # Assign +v+ to every column matching +name+ (a partial key, or a full MultiIndex tuple that is
609
+ # duplicated across several positions). For a MultiIndex, resolve to positions directly: a
610
+ # duplicated tuple re-looked-up by name would return a MultiIndex instead of an integer.
611
+ def assign_multiple_vectors(name, pos, v)
612
+ positions = @vectors.is_a?(MultiIndex) ? @vectors.positions_for(name.flatten) : pos.map { |p| @vectors[p] }
613
+ # Give all but the first match an independent copy so duplicate columns don't share one
614
+ # Vector instance (a later cell mutation would otherwise leak across both).
615
+ positions.each_with_index { |position, i| @data[position] = i.zero? ? v : v.dup }
606
616
  end
607
617
 
608
618
  def assign_or_add_vector_rough(name, v)
@@ -764,11 +774,42 @@ module DaruLite
764
774
  end
765
775
 
766
776
  def initialize_from_array_of_vectors(source, vectors, index, opts)
777
+ # Build @data positionally rather than round-tripping through a name-keyed hash: duplicate
778
+ # MultiIndex tuples would collapse into a single hash entry and corrupt the columns.
779
+ @vectors = Index.coerce(vectors)
780
+
781
+ first_index = source.first.index
782
+ vectors_have_same_index = source.all? { |vector| first_index == vector.index }
783
+
767
784
  clone = opts[:clone] != false
768
- hsh = vectors.each_with_index.to_h do |name, idx|
769
- [name, source[idx]]
785
+ clone = true unless index || vectors_have_same_index
786
+
787
+ @index = deduce_index_from_vectors(index, source, first_index, vectors_have_same_index)
788
+
789
+ @data =
790
+ if !clone
791
+ source.dup
792
+ elsif vectors_have_same_index
793
+ source.map(&:dup)
794
+ else
795
+ source.map.with_index { |vector, position| reindex_vector(vector, @vectors.to_a[position]) }
796
+ end
797
+ end
798
+
799
+ def deduce_index_from_vectors(index, source, first_index, vectors_have_same_index)
800
+ if index
801
+ Index.coerce(index)
802
+ elsif vectors_have_same_index
803
+ first_index.dup
804
+ else
805
+ DaruLite::Index.new(source.map { |vector| vector.index.to_a }.flatten.uniq.sort)
806
+ end
807
+ end
808
+
809
+ def reindex_vector(vector, name)
810
+ DaruLite::Vector.new([], name: name, index: @index).tap do |new_vector|
811
+ @index.each { |idx| new_vector[idx] = vector.index.include?(idx) ? vector[idx] : nil }
770
812
  end
771
- initialize(hsh, index: index, order: vectors, name: @name, clone: clone)
772
813
  end
773
814
 
774
815
  def initialize_from_array_of_hashes(source, vectors, index, _opts)
@@ -1,5 +1,9 @@
1
1
  module DaruLite
2
2
  class CategoricalIndex < Index
3
+ UNSUPPORTED_RANGE_MSG =
4
+ 'CategoricalIndex does not support label-range slicing: ' \
5
+ 'categories are unordered and may repeat'.freeze
6
+
3
7
  # Create a categorical index object.
4
8
  # @param indexes [Array<object>] array of indexes
5
9
  # @return [DaruLite::CategoricalIndex] categorical index
@@ -51,6 +55,10 @@ module DaruLite
51
55
  # x.pos :a, 1
52
56
  # # => [0, 1, 2, 3]
53
57
  def pos(*indexes)
58
+ if indexes.size == 1 && indexes.first.is_a?(Range) && !include?(indexes.first)
59
+ return pos_from_range(indexes.first)
60
+ end
61
+
54
62
  positions = indexes.map do |index|
55
63
  if include? index
56
64
  @cat_hash[index]
@@ -81,6 +89,35 @@ module DaruLite
81
89
  nil
82
90
  end
83
91
 
92
+ # Returns the category located at a positional value.
93
+ # Mirrors Index#key but reads the categorical structure instead of @keys,
94
+ # which CategoricalIndex never populates.
95
+ # @param value [Integer] positional value
96
+ # @return [object, nil] category at the position, or nil if out of range
97
+ # @example
98
+ # idx = DaruLite::CategoricalIndex.new [:a, :b, :a]
99
+ # idx.key 1 # => :b
100
+ # idx.key 99 # => nil
101
+ def key(value)
102
+ return nil unless value.is_a?(Numeric)
103
+
104
+ to_a[value]
105
+ end
106
+
107
+ # Label-range slicing is not supported: categories are unordered and may
108
+ # repeat, so a range of positions between two labels is ambiguous.
109
+ # @raise [ArgumentError] always
110
+ def slice(*)
111
+ raise ArgumentError, UNSUPPORTED_RANGE_MSG
112
+ end
113
+
114
+ # Label-range slicing is not supported: categories are unordered and may
115
+ # repeat, so a range of positions between two labels is ambiguous.
116
+ # @raise [ArgumentError] always
117
+ def subset_slice(*)
118
+ raise ArgumentError, UNSUPPORTED_RANGE_MSG
119
+ end
120
+
84
121
  # Returns index value from position
85
122
  # @param pos [Integer] the position to look for
86
123
  # @return [object] category corresponding to position
@@ -206,6 +243,31 @@ module DaruLite
206
243
 
207
244
  private
208
245
 
246
+ # Single-key position lookup, resolved against the categorical structure.
247
+ # Overrides Index#numeric_pos, which reads the never-populated @relation_hash.
248
+ # @raise [IndexError] when the key is neither a valid category nor position
249
+ def numeric_pos(key)
250
+ pos(key)
251
+ end
252
+
253
+ # Label-range slicing is not supported (see #slice / #subset_slice).
254
+ # @raise [ArgumentError] always
255
+ def preprocess_range(_rng)
256
+ raise ArgumentError, UNSUPPORTED_RANGE_MSG
257
+ end
258
+
259
+ # A positional (integer) range resolves to the positions it spans, mirroring
260
+ # pandas iloc / Vector#head / #tail. A non-integer label range is rejected
261
+ # because categories are unordered and may repeat (like pandas .loc on an
262
+ # unordered CategoricalIndex).
263
+ # @raise [ArgumentError] when the range bounds are not integers
264
+ def pos_from_range(rng)
265
+ raise ArgumentError, UNSUPPORTED_RANGE_MSG unless rng.begin.is_a?(Integer) && rng.end.is_a?(Integer)
266
+
267
+ # size.times.to_a[rng] clamps to valid positions; nil (fully out of range) -> []
268
+ size.times.to_a[rng] || []
269
+ end
270
+
209
271
  def int_from_cat(cat)
210
272
  @cat_hash.keys.index cat
211
273
  end
@@ -227,22 +227,40 @@ module DaruLite
227
227
  collection.one? ? collection.first : collection
228
228
  end
229
229
 
230
- def retrieve_from_tuple(key)
230
+ # Return every integer position whose tuple matches the (possibly partial) key.
231
+ # Unlike #retrieve_from_tuple, the return type is always an Array of positions,
232
+ # even when a full tuple is duplicated across several positions.
233
+ #
234
+ # @param key [Array] a full or left-anchored partial tuple
235
+ # @return [Array<Integer>] matching positions, in ascending order
236
+ def positions_for(key)
231
237
  chosen = []
232
238
 
233
239
  key.each_with_index do |k, depth|
234
- level_index = @levels[depth][k]
240
+ # An over-long key runs past the defined levels; guard explicitly rather than letting
241
+ # nil[k] raise NoMethodError.
242
+ level = @levels[depth]
243
+ raise IndexError, "Specified index #{key.inspect} do not exist" if level.nil?
244
+
245
+ level_index = level[k]
235
246
  raise IndexError, "Specified index #{key.inspect} do not exist" if level_index.nil?
236
247
 
237
- label = @labels[depth]
238
- chosen = find_all_indexes label, level_index, chosen
248
+ chosen = find_all_indexes(@labels[depth], level_index, chosen)
239
249
  end
240
250
 
251
+ chosen
252
+ end
253
+
254
+ def retrieve_from_tuple(key)
255
+ chosen = positions_for(key)
256
+
257
+ # Every level component existed individually but no tuple matches the full combination
258
+ # (e.g. [:a, :two, :foo] when that triple was never indexed together).
259
+ raise IndexError, "Specified index #{key.inspect} do not exist" if chosen.empty?
260
+
241
261
  return chosen[0] if chosen.size == 1 && key.size == @levels.size
242
262
 
243
263
  multi_index_from_multiple_selections(chosen)
244
- rescue NoMethodError
245
- raise IndexError, "Specified index #{key.inspect} do not exist"
246
264
  end
247
265
 
248
266
  def multi_index_from_multiple_selections(chosen)
@@ -166,7 +166,9 @@ module DaruLite
166
166
  private
167
167
 
168
168
  def apply_method_to_numerics(method, *)
169
- numerics = @vectors.to_a.map { |n| [n, @data[@vectors[n]]] }.select { |_n, v| v.numeric? }
169
+ numerics = @data.each_with_index
170
+ .map { |v, position| [@vectors.to_a[position], v] }
171
+ .select { |_n, v| v.numeric? }
170
172
  computed = numerics.map { |_n, v| v.send(method, *) }
171
173
 
172
174
  DaruLite::DataFrame.new(computed, index: @index, order: numerics.map(&:first), clone: false)
@@ -187,10 +189,12 @@ module DaruLite
187
189
  end
188
190
 
189
191
  def compute_stats(method)
190
- DaruLite::Vector.new(
191
- numeric_vectors.to_h { [it, self[it].send(method)] },
192
- name: method
193
- )
192
+ # Iterate by position (via each_vector_with_index) instead of `self[name]`: a MultiIndex
193
+ # with duplicate tuples would make the name lookup ambiguous and collapse the result.
194
+ stats = each_vector_with_index.select { |vector, _name| vector.numeric? }
195
+ .map { |vector, name| [name, vector.send(method)] }
196
+
197
+ DaruLite::Vector.new(stats.map(&:last), index: stats.map(&:first), name: method)
194
198
  end
195
199
  alias sds std
196
200
  alias variance variance_sample
@@ -1,3 +1,3 @@
1
1
  module DaruLite
2
- VERSION = '0.3.3'.freeze
2
+ VERSION = '0.3.4'.freeze
3
3
  end
@@ -1189,6 +1189,32 @@ describe DaruLite::Vector, "categorical" do
1189
1189
  end
1190
1190
  end
1191
1191
 
1192
+ context "positional slicing on a CategoricalIndex-backed Vector" do
1193
+ let(:dv) do
1194
+ DaruLite::Vector.new(
1195
+ [10, 20, 30, 40, 50],
1196
+ index: DaruLite::CategoricalIndex.new([:a, :b, :a, :a, :c])
1197
+ )
1198
+ end
1199
+
1200
+ context "#head" do
1201
+ it { expect(dv.head(2).to_a).to eq [10, 20] }
1202
+ it { expect(dv.head(99).to_a).to eq [10, 20, 30, 40, 50] }
1203
+ end
1204
+
1205
+ context "#tail" do
1206
+ it { expect(dv.tail(2).to_a).to eq [40, 50] }
1207
+ end
1208
+
1209
+ context "#last" do
1210
+ it { expect(dv.last(2).to_a).to eq [40, 50] }
1211
+ end
1212
+
1213
+ context "positional range" do
1214
+ it { expect(dv[0..2].to_a).to eq [10, 20, 30] }
1215
+ end
1216
+ end
1217
+
1192
1218
  context "#set_at" do
1193
1219
  context DaruLite::Index do
1194
1220
  let (:idx) { DaruLite::Index.new [1, 0, :c] }
@@ -23,6 +23,29 @@ shared_examples_for 'a duplicatable DataFrame' do
23
23
  expect(subject.vectors.object_id).not_to eq(df_mi.vectors.object_id)
24
24
  expect(subject.index.object_id).not_to eq(df_mi.index.object_id)
25
25
  end
26
+
27
+ context "with duplicate tuples" do
28
+ let(:dup_df) do
29
+ DaruLite::DataFrame.new(
30
+ [[1, 2], [3, 4], [5, 6], [7, 8]],
31
+ order: DaruLite::MultiIndex.from_tuples([
32
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
33
+ [:percentage, 'Wave'], [:percentage, 'Wave']
34
+ ]),
35
+ index: %w[Yes No]
36
+ )
37
+ end
38
+
39
+ it "dups without corrupting the columns and preserves the MultiIndex" do
40
+ copy = dup_df.dup
41
+
42
+ expect(copy).to eq(dup_df)
43
+ expect(copy.vectors).to be_a(DaruLite::MultiIndex)
44
+ expect(copy.vectors).to eq(dup_df.vectors)
45
+ expect(copy.data.map(&:to_a)).to eq([[1, 2], [3, 4], [5, 6], [7, 8]])
46
+ expect(copy.vectors.object_id).not_to eq(dup_df.vectors.object_id)
47
+ end
48
+ end
26
49
  end
27
50
  end
28
51
 
@@ -79,6 +79,35 @@ shared_examples_for 'a fetchable DataFrame' do
79
79
  )
80
80
  end
81
81
  end
82
+
83
+ context "with duplicate tuples in a MultiIndex" do
84
+ let(:dup_df) do
85
+ DaruLite::DataFrame.new(
86
+ [[1, 2], [3, 4], [5, 6], [7, 8]],
87
+ order: DaruLite::MultiIndex.from_tuples([
88
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
89
+ [:percentage, 'Wave'], [:percentage, 'Wave']
90
+ ]),
91
+ index: %w[Yes No]
92
+ )
93
+ end
94
+
95
+ it "returns a DataFrame with all matching columns for a partial key" do
96
+ result = dup_df[:weighted_count]
97
+
98
+ expect(result).to be_a(DaruLite::DataFrame)
99
+ expect(result.ncols).to eq(2)
100
+ expect(result.map_vectors(&:to_a)).to eq([[1, 2], [3, 4]])
101
+ expect(result.index).to eq(dup_df.index)
102
+ end
103
+
104
+ it "returns a DataFrame with all matching columns for a duplicate full tuple" do
105
+ result = dup_df[:weighted_count, 'Wave']
106
+
107
+ expect(result).to be_a(DaruLite::DataFrame)
108
+ expect(result.map_vectors(&:to_a)).to eq([[1, 2], [3, 4]])
109
+ end
110
+ end
82
111
  end
83
112
 
84
113
  describe "#at" do
@@ -25,6 +25,22 @@ shared_examples_for 'an iterable DataFrame' do
25
25
 
26
26
  expect(ret).to eq(df)
27
27
  end
28
+
29
+ it "iterates over vectors of a MultiIndex with duplicate tuples" do
30
+ order = DaruLite::MultiIndex.from_tuples([
31
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
32
+ [:percentage, 'Wave'], [:percentage, 'Wave']
33
+ ])
34
+ dup_df = DaruLite::DataFrame.new([[1, 2], [3, 4], [5, 6], [7, 8]], order: order, index: %w[Yes No])
35
+
36
+ tuples = []
37
+ dup_df.each_vector_with_index do |vector, index|
38
+ tuples << index
39
+ expect(vector).to be_a(DaruLite::Vector)
40
+ end
41
+
42
+ expect(tuples).to eq(order.to_a)
43
+ end
28
44
  end
29
45
 
30
46
  describe "#each_row_with_index" do
@@ -221,6 +221,37 @@ shared_examples_for 'a setable DataFrame' do
221
221
 
222
222
  expect(df_empty[:c, :one, :bar].name).to eq "conebar"
223
223
  end
224
+
225
+ context "with duplicate tuples" do
226
+ let(:dup_df) do
227
+ DaruLite::DataFrame.new(
228
+ [[1, 2], [3, 4], [5, 6], [7, 8]],
229
+ order: DaruLite::MultiIndex.from_tuples([
230
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
231
+ [:percentage, 'Wave'], [:percentage, 'Wave']
232
+ ]),
233
+ index: %w[Yes No]
234
+ )
235
+ end
236
+
237
+ it "assigns every column matching a duplicate full tuple" do
238
+ dup_df[:weighted_count, 'Wave'] = [10, 20]
239
+
240
+ expect(dup_df.data.map(&:to_a)).to eq([[10, 20], [10, 20], [5, 6], [7, 8]])
241
+ end
242
+
243
+ it "assigns independent Vector instances to duplicate columns" do
244
+ dup_df[:weighted_count, 'Wave'] = [10, 20]
245
+
246
+ expect(dup_df.data[0].object_id).not_to eq(dup_df.data[1].object_id)
247
+ end
248
+
249
+ it "assigns every column matching a partial key" do
250
+ dup_df[:percentage] = [99, 98]
251
+
252
+ expect(dup_df.data.map(&:to_a)).to eq([[1, 2], [3, 4], [99, 98], [99, 98]])
253
+ end
254
+ end
224
255
  end
225
256
  end
226
257
 
@@ -240,7 +240,25 @@ shared_examples_for 'a sortable DataFrame' do
240
240
  end
241
241
 
242
242
  context DaruLite::MultiIndex do
243
- pending
243
+ context "with duplicate tuples" do
244
+ let(:dup_df) do
245
+ DaruLite::DataFrame.new(
246
+ [[1, 2], [3, 4], [5, 6], [7, 8]],
247
+ order: DaruLite::MultiIndex.from_tuples([
248
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
249
+ [:percentage, 'Wave'], [:percentage, 'Wave']
250
+ ]),
251
+ index: %w[Yes No]
252
+ )
253
+ end
254
+
255
+ it "sorts by a duplicated vector without raising" do
256
+ sorted = dup_df.sort([[:percentage, 'Wave']], ascending: [false])
257
+
258
+ expect(sorted.index.to_a).to eq(%w[No Yes])
259
+ expect(sorted.data.map(&:to_a)).to eq([[2, 1], [4, 3], [6, 5], [8, 7]])
260
+ end
261
+ end
244
262
  end
245
263
 
246
264
  context DaruLite::CategoricalIndex do
@@ -148,6 +148,18 @@ describe DaruLite::DataFrame do
148
148
  expect(df.a) .to eq(DaruLite::Vector.new([2]*5))
149
149
  end
150
150
 
151
+ it "names the reindexed vectors after their order labels when indexes differ" do
152
+ df = DaruLite::DataFrame.new(
153
+ [
154
+ DaruLite::Vector.new([1, 2], index: [:one, :two]),
155
+ DaruLite::Vector.new([3, 4], index: [:two, :three])
156
+ ],
157
+ order: [:b, :a]
158
+ )
159
+
160
+ expect(df.data.map(&:name)).to eq([:b, :a])
161
+ end
162
+
151
163
  it "accepts Index objects for row/col" do
152
164
  rows = DaruLite::Index.new [:one, :two, :three, :four, :five]
153
165
  cols = DaruLite::Index.new [:a, :b]
@@ -865,6 +877,30 @@ describe DaruLite::DataFrame do
865
877
  index: [:one, :two, :three, :four, :five]))
866
878
  end
867
879
  end
880
+
881
+ context "with duplicate tuples in a MultiIndex" do
882
+ let(:dup_df) do
883
+ DaruLite::DataFrame.new(
884
+ [[1, 2], [3, 4], [5, 6], [7, 8]],
885
+ order: DaruLite::MultiIndex.from_tuples([
886
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
887
+ [:percentage, 'Wave'], [:percentage, 'Wave']
888
+ ]),
889
+ index: %w[Yes No]
890
+ )
891
+ end
892
+
893
+ it "deletes the first matching column, leaving the other in place" do
894
+ dup_df.delete_vector([:weighted_count, 'Wave'])
895
+
896
+ expect(dup_df.ncols).to eq(3)
897
+ expect(dup_df.vectors).to be_a(DaruLite::MultiIndex)
898
+ expect(dup_df.vectors.to_a).to eq([
899
+ [:weighted_count, 'Wave'], [:percentage, 'Wave'], [:percentage, 'Wave']
900
+ ])
901
+ expect(dup_df.data.map(&:to_a)).to eq([[3, 4], [5, 6], [7, 8]])
902
+ end
903
+ end
868
904
  end
869
905
 
870
906
  context "#delete_vectors" do
@@ -32,6 +32,24 @@ describe DaruLite::CategoricalIndex do
32
32
  it { expect { index.pos :e }.to raise_error IndexError }
33
33
  end
34
34
 
35
+ context "label range argument" do
36
+ it { expect { index.pos :a..:c }.to raise_error ArgumentError }
37
+ end
38
+
39
+ context "positional (integer) range argument" do
40
+ it { expect(index.pos(0..2)).to eq [0, 1, 2] }
41
+
42
+ context "when the range extends past the end" do
43
+ it { expect(index.pos(0..99)).to eq [0, 1, 2, 3, 4] }
44
+ end
45
+ end
46
+
47
+ context "when a category is itself a Range" do
48
+ let(:index) { described_class.new [(1..2), (3..4), (1..2)] }
49
+
50
+ it { expect(index.pos(1..2)).to eq [0, 2] }
51
+ end
52
+
35
53
  context "positional index" do
36
54
  it { expect(index.pos 0).to eq 0 }
37
55
  end
@@ -98,6 +116,56 @@ describe DaruLite::CategoricalIndex do
98
116
 
99
117
  it { is_expected.to be_nil }
100
118
  end
119
+
120
+ context "when given a label range" do
121
+ it { expect { index[:a..:c] }.to raise_error ArgumentError }
122
+ end
123
+
124
+ context "when given a positional (integer) range" do
125
+ subject { index[0..2] }
126
+
127
+ it { is_expected.to eq [0, 1, 2] }
128
+ end
129
+
130
+ context "when a category is itself a Range" do
131
+ let(:index) { described_class.new [(1..2), (3..4), (1..2)] }
132
+
133
+ it { expect(index[1..2]).to eq [0, 2] }
134
+ end
135
+ end
136
+
137
+ describe "#key" do
138
+ context "when given a position" do
139
+ subject { index.key(1) }
140
+
141
+ it { is_expected.to eq :b }
142
+ end
143
+
144
+ context "when the position holds a duplicated category" do
145
+ subject { index.key(3) }
146
+
147
+ it { is_expected.to eq :a }
148
+ end
149
+
150
+ context "when the position is out of range" do
151
+ subject { index.key(99) }
152
+
153
+ it { is_expected.to be_nil }
154
+ end
155
+
156
+ context "when given a non-numeric value" do
157
+ subject { index.key(:a) }
158
+
159
+ it { is_expected.to be_nil }
160
+ end
161
+ end
162
+
163
+ describe "#slice" do
164
+ it { expect { index.slice(:a, :c) }.to raise_error ArgumentError }
165
+ end
166
+
167
+ describe "#subset_slice" do
168
+ it { expect { index.subset_slice(:a, :c) }.to raise_error ArgumentError }
101
169
  end
102
170
 
103
171
  context "#subset" do
@@ -542,6 +542,45 @@ describe DaruLite::MultiIndex do
542
542
  # TODO: Add specs for IndexError
543
543
  end
544
544
 
545
+ context "#positions_for" do
546
+ let(:idx) do
547
+ described_class.from_tuples([
548
+ [:b, :one, :bar],
549
+ [:b, :two, :bar],
550
+ [:b, :two, :baz],
551
+ [:b, :one, :foo]
552
+ ])
553
+ end
554
+
555
+ it "returns all matching positions for a partial key" do
556
+ expect(idx.positions_for([:b, :one])).to eq([0, 3])
557
+ end
558
+
559
+ it "returns a single-element array for a full unique tuple" do
560
+ expect(idx.positions_for([:b, :one, :bar])).to eq([0])
561
+ end
562
+
563
+ it "returns every position for duplicate tuples" do
564
+ dup_idx = described_class.from_tuples([
565
+ [:weighted_count, 'Wave'],
566
+ [:weighted_count, 'Wave'],
567
+ [:percentage, 'Wave'],
568
+ [:percentage, 'Wave']
569
+ ])
570
+
571
+ expect(dup_idx.positions_for([:weighted_count, 'Wave'])).to eq([0, 1])
572
+ expect(dup_idx.positions_for([:weighted_count])).to eq([0, 1])
573
+ end
574
+
575
+ it "raises IndexError for a non-existent key" do
576
+ expect { idx.positions_for([:z]) }.to raise_error(IndexError)
577
+ end
578
+
579
+ it "raises IndexError (not NoMethodError) for a key longer than the levels" do
580
+ expect { idx.positions_for([:b, :one, :bar, :extra]) }.to raise_error(IndexError)
581
+ end
582
+ end
583
+
545
584
  context "#subset" do
546
585
  let(:idx) do
547
586
  described_class.from_tuples([
@@ -20,6 +20,19 @@ describe DaruLite::DataFrame do
20
20
  it "calculates mean of multi level numeric only vectors and returns values in a DataFrame" do
21
21
  # TODO - pending
22
22
  end
23
+
24
+ it "calculates mean of a MultiIndex with duplicate tuples without raising" do
25
+ dup_df = DaruLite::DataFrame.new(
26
+ [[1, 3], [2, 4], [5, 7], [6, 8]],
27
+ order: DaruLite::MultiIndex.from_tuples([
28
+ [:weighted_count, 'Wave'], [:weighted_count, 'Wave'],
29
+ [:percentage, 'Wave'], [:percentage, 'Wave']
30
+ ]),
31
+ index: %w[Yes No]
32
+ )
33
+
34
+ expect(dup_df.mean.to_a).to eq([2.0, 3.0, 6.0, 7.0])
35
+ end
23
36
  end
24
37
 
25
38
  context "#variance_sample" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daru_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Naude-Filonnière
@@ -550,7 +550,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
550
550
  - !ruby/object:Gem::Version
551
551
  version: '0'
552
552
  requirements: []
553
- rubygems_version: 4.0.9
553
+ rubygems_version: 4.0.15
554
554
  specification_version: 4
555
555
  summary: Data Analysis in RUby, stripped down
556
556
  test_files: