Dictionaries and dataframes#
Needing a better way of ordering dictionaries was one of the original inspirations for Sciris back in 2014. In those dark days of Python <=3.6, dictionaries were unordered, which meant that dict.keys()
could give you anything. (And you still can’t do dict.keys()[0]
, much less dict[0]
). This tutorial describes Sciris’ ordered dict, the odict
, its close cousin the objdict
, and its pandas-powered pseudorelative, the dataframe
.
Click here to open an interactive version of this notebook.
The odict
#
In basically every situation except one, an odict
can be used like a dict
. (Since this is a tutorial, see if you can intuit what that one situation is!) For example, creating an odict
works just like creating a regular dict:
[1]:
import sciris as sc
od = sc.odict(a=['some', 'strings'], b=[1,2,3])
print(od)
#0: 'a': ['some', 'strings']
#1: 'b': [1, 2, 3]
Okay, it doesn’t exactly look like a dict, but it is one:
[2]:
print(f'Keys: {od.keys()}')
print(f'Values: {od.values()}')
print(f'Items: {od.items()}')
Keys: ['a', 'b']
Values: [['some', 'strings'], [1, 2, 3]]
Items: [('a', ['some', 'strings']), ('b', [1, 2, 3])]
Looks pretty much the same as a regular dict, except that od.keys()
returns a regular list (so, yes, you can do od.keys()[0]
). But, you can do things you can’t do with a regular dict, such as:
[3]:
for i,k,v in od.enumitems():
print(f'Item {i} is called {k} and has value {v}')
Item 0 is called a and has value ['some', 'strings']
Item 1 is called b and has value [1, 2, 3]
We can, as you probably guessed, also retrieve items by index as well:
[4]:
print(od['a'])
print(od[0])
['some', 'strings']
['some', 'strings']
Remember the question about the situation where you wouldn’t use an odict? The answer is if your dict has integer keys, then although you still could use an odict
, it’s probably best to use a regular dict
. But even float keys are fine to use (if somewhat strange).
You might’ve noticed that the odict
has more verbose output than a regular dict. This is because its primary purpose is as a high-level container for storing large(ish) objects.
For example, let’s say we want to store a number of named simulation results. Look at how we’re able to leverage the odict
in the loop that creates the plots
[5]:
import numpy as np
import pylab as pl
class Sim:
def __init__(self, n=20, n_factors=6):
self.results = sc.odict()
self.n = n
self.n_factors = n_factors
def run(self):
for i in range(self.n_factors):
label = f'y = N^{i+1}'
result = np.random.randn(self.n)**(i+1)
self.results[label] = result
def plot(self):
with sc.options.context(jupyter=True): # Jupyter-optimized plotting
pl.figure()
rows,cols = sc.getrowscols(len(self.results))
for i,label,result in self.results.enumitems(): # odict magic!
pl.subplot(rows, cols, i+1)
pl.scatter(np.arange(self.n), result, c=result, cmap='parula')
pl.title(label)
sc.figlayout() # Trim whitespace from the figure
sim = Sim()
sim.run()
sim.plot()

We can quickly access these results for exploratory data analysis without having to remember and type the labels explicitly:
[6]:
print('Sim results are')
print(sim.results)
print('The first set of results is')
print(sim.results[0])
print('The first set of results has median')
sc.printmedian(sim.results[0])
Sim results are
#0: 'y = N^1':
array([-1.32008427, 0.6586819 , 0.42730321, -0.9927497 , -0.09441888,
0.54045703, 1.96446308, 0.30495523, -0.02315098, -1.0960319 ,
-0.23211463, 0.47210925, -0.67868214, -1.59732916, -0.4355556 ,
-1.54681135, -1.46300615, -0.12269337, -1.0497002 , -0.07460847])
#1: 'y = N^2':
array([3.07146540e+00, 5.77760698e-01, 1.66919968e+00, 1.79442616e+00,
2.19292140e+00, 1.39705985e+00, 5.39043015e-01, 4.36763272e-02,
1.03053905e-01, 9.60069759e-03, 1.70151923e+00, 4.92307650e-01,
1.91415710e-03, 6.33533357e-02, 7.58086942e-04, 9.94638951e-04,
3.88256863e-01, 6.69714527e-01, 7.78378487e-02, 1.05115950e+00])
#2: 'y = N^3':
array([-4.61850177e-03, -9.73657479e-01, 1.03797670e-03, -8.54556532e-01,
4.54693962e-05, 2.02136826e+00, -7.81023555e-02, 3.13267393e-04,
3.95781134e-01, -3.00955369e-01, 2.01513815e-06, 8.02930006e-02,
1.36202939e+00, -1.75365840e-02, -5.45623711e-02, 7.77839353e-03,
7.51699126e-02, -2.09292306e-01, 2.34662023e+00, 8.43794461e-01])
#3: 'y = N^4':
array([2.28936766e-02, 1.00685447e+00, 1.04512301e-03, 8.58281504e-04,
2.04594788e+00, 5.96959561e+00, 5.77338935e-02, 4.35119839e-02,
2.81981027e-07, 1.20090005e-07, 1.33813745e-01, 3.46031611e+00,
1.94538914e+00, 1.24548550e-05, 2.02492034e-04, 7.43276659e-01,
7.70187297e+00, 4.69986302e+01, 8.26575279e-03, 5.81687555e-01])
#4: 'y = N^5':
array([ 8.05331179e-05, -2.91980158e-01, 1.19635162e+00, -2.54098654e-02,
-2.29206226e-04, -5.52772643e-02, 2.44466389e-10, 9.68568548e-03,
1.74804863e+01, 2.70118831e-04, -3.51671034e-01, 1.50636335e-06,
-2.58992962e-02, -2.52255426e+01, 3.44134395e-01, 3.16953570e+01,
-1.58975128e-02, 1.64638925e-03, 8.74699767e-02, -5.98184970e-04])
#5: 'y = N^6':
array([3.63691402e-07, 1.53089667e+02, 4.59181814e+01, 5.25060283e+00,
5.72546372e-03, 2.84250537e-03, 1.79139175e-09, 4.71731846e-06,
6.06003379e+00, 3.87990056e+00, 6.29353415e+00, 2.13693585e-04,
1.78093488e+02, 5.31871924e+00, 2.03416286e-01, 6.35030594e-03,
2.08924968e-01, 3.18600194e+00, 6.30675951e+01, 2.02246138e+00])
The first set of results is
[-1.32008427 0.6586819 0.42730321 -0.9927497 -0.09441888 0.54045703
1.96446308 0.30495523 -0.02315098 -1.0960319 -0.23211463 0.47210925
-0.67868214 -1.59732916 -0.4355556 -1.54681135 -1.46300615 -0.12269337
-1.0497002 -0.07460847]
The first set of results has median
-0.177 (95% CI: -1.573, 1.344)
This is a have-your-cake-and-eat-it-too situation: the first set of results is correctly labeled (sim.results['y = N^1']
), but you can easily access it without having to type all that (sim.results[0]
).
The objdict
#
When you’re just writing throwaway analysis code, it can be a pain to type mydict['key1']['key2']
over and over. (Right-pinky overuse is a real medical issue.) Wouldn’t it be nice if you could just type mydict.key1.key2
, but otherwise have everything work exactly like a dict? This is where the objdict
comes in: it’s identical to an odict
(and hence like a regular dict
), except you can use “object syntax” (a.b
)
instead of “dict syntax” (a['b']
). This is especially handy for using f-strings, since you don’t have to worry about nested quotes:
[7]:
ob = sc.objdict(key1=['some', 'strings'], key2=[1,2,3])
print(f'Checking {ob[0] = }')
print(f'Checking {ob.key1 = }')
print(f'Checking {ob["key1"] = }') # We need to use double-quotes inside since single quotes are taken!
Checking ob[0] = ['some', 'strings']
Checking ob.key1 = ['some', 'strings']
Checking ob["key1"] = ['some', 'strings']
In most cases, you probably want to use objdict
s rather than odict
s just to have the extra flexibility. Why would you ever use an odict
over an objdict
? Mostly just because there’s small but nonzero overhead in doing the extra attribute checking: odict
is faster (faster than even collections.OrderedDict
, though slower than a plain dict
). The differences are tiny (literally nanoseconds) so won’t matter unless you’re doing millions of operations. But if you’re
reading this, chances are high that you do sometimes need to do millions of dict operations.
Dataframes#
The Sciris sc.dataframe()
works exactly like pandas pd.DataFrame()
, with a couple extra features, mostly to do with creation, indexing, and manipulation.
Dataframe creation#
Any valid pandas
dataframe initialization works exactly the same in Sciris. However, Sciris is a bit more flexible about how you can create the dataframe, again optimized for letting you make them quickly with minimal code. For example:
[8]:
import pandas as pd
x = ['a','b','c']
y = [1, 2, 3]
z = [1, 0, 1]
df = pd.DataFrame(dict(x=x, y=y, z=z)) # Pandas
df = sc.dataframe(x=x, y=y, z=z) # Sciris
It’s not a huge difference, but the Sciris one is shorter. Sciris also makes it easier to define types on dataframe creation:
[9]:
df = sc.dataframe(x=x, y=y, z=z, dtypes=[str, float, bool])
print(df)
x y z
0 a 1.0 True
1 b 2.0 False
2 c 3.0 True
You can also define data types along with the columns:
[10]:
columns = dict(x=str, y=float, z=bool)
data = [
['a', 1, 1],
['b', 2, 0],
['c', 3, 1],
]
df = sc.dataframe(columns=columns, data=data)
df.disp()
x y z
0 a 1.0 True
1 b 2.0 False
2 c 3.0 True
The df.disp()
command will do its best to show the full dataframe. By default, Sciris dataframes (just like pandas) are shown in abbreviated form:
[11]:
df = sc.dataframe(data=np.random.rand(70,10))
print(df)
0 1 2 3 4 5 6 \
0 0.822711 0.110239 0.906056 0.481981 0.361881 0.893028 0.695297
1 0.217234 0.720117 0.350314 0.422159 0.560442 0.455415 0.249465
2 0.818199 0.157209 0.452704 0.718493 0.845782 0.532313 0.230762
3 0.482231 0.664431 0.943167 0.116088 0.422419 0.865513 0.631439
4 0.647647 0.641324 0.980744 0.864609 0.740659 0.535572 0.551938
.. ... ... ... ... ... ... ...
65 0.222550 0.209633 0.600340 0.933563 0.473279 0.684217 0.465604
66 0.874617 0.028764 0.869738 0.148016 0.958270 0.571235 0.606268
67 0.803615 0.533806 0.397369 0.137051 0.795589 0.779462 0.114743
68 0.116092 0.510080 0.867203 0.961734 0.450690 0.098703 0.894338
69 0.730965 0.839132 0.176861 0.962916 0.768946 0.645052 0.861573
7 8 9
0 0.035407 0.745024 0.504308
1 0.265651 0.866679 0.656623
2 0.630489 0.325640 0.438372
3 0.281713 0.132525 0.850373
4 0.077983 0.547021 0.490069
.. ... ... ...
65 0.602869 0.594502 0.023580
66 0.410026 0.965781 0.643058
67 0.485557 0.241892 0.597270
68 0.722918 0.506580 0.500258
69 0.567778 0.441246 0.030110
[70 rows x 10 columns]
But sometimes you just want to see the whole thing. The official way to do it in pandas is with pd.options_context
, but this is a lot of effort if you’re just poking around in a script or terminal (which, if you’re printing a dataframe, you probably are). By default, df.disp()
shows the whole damn thing:
[12]:
df.disp()
0 1 2 3 4 5 6 7 8 9
0 0.8227 0.1102 0.9061 0.4820 0.3619 0.8930 0.6953 0.0354 0.7450 0.5043
1 0.2172 0.7201 0.3503 0.4222 0.5604 0.4554 0.2495 0.2657 0.8667 0.6566
2 0.8182 0.1572 0.4527 0.7185 0.8458 0.5323 0.2308 0.6305 0.3256 0.4384
3 0.4822 0.6644 0.9432 0.1161 0.4224 0.8655 0.6314 0.2817 0.1325 0.8504
4 0.6476 0.6413 0.9807 0.8646 0.7407 0.5356 0.5519 0.0780 0.5470 0.4901
5 0.6205 0.4675 0.3341 0.5409 0.2225 0.9341 0.9847 0.6868 0.3177 0.3995
6 0.4927 0.8405 0.5118 0.3981 0.7851 0.8053 0.5457 0.3753 0.4341 0.6055
7 0.6282 0.7565 0.1253 0.8370 0.2017 0.1783 0.8077 0.4965 0.2549 0.8676
8 0.6378 0.4044 0.0385 0.7950 0.9916 0.0800 0.2156 0.9506 0.4044 0.8485
9 0.3460 0.4882 0.1705 0.6183 0.7851 0.9988 0.0754 0.8503 0.8594 0.7935
10 0.3083 0.0292 0.4597 0.3193 0.8401 0.3725 0.9022 0.1452 0.2418 0.0523
11 0.8133 0.0761 0.2412 0.6700 0.3341 0.1603 0.7613 0.5370 0.2064 0.8942
12 0.0851 0.0464 0.8301 0.8066 0.3169 0.4011 0.8187 0.2850 0.2042 0.2316
13 0.5827 0.1514 0.0501 0.5929 0.6052 0.9210 0.8539 0.8289 0.8321 0.5471
14 0.6078 0.4982 0.5307 0.2503 0.1874 0.2580 0.4290 0.7384 0.4370 0.0727
15 0.0708 0.8505 0.9444 0.4801 0.2345 0.5682 0.4422 0.1196 0.0651 0.2930
16 0.8981 0.7249 0.9706 0.5902 0.8638 0.2547 0.3901 0.6728 0.0104 0.8009
17 0.6939 0.1788 0.5964 0.7850 0.1715 0.1857 0.6396 0.8933 0.4225 0.9895
18 0.0012 0.9556 0.5522 0.0062 0.5794 0.6654 0.1232 0.8361 0.1830 0.4280
19 0.7790 0.6813 0.4377 0.7036 0.7136 0.4714 0.2822 0.5002 0.7544 0.2770
20 0.9106 0.5987 0.5042 0.4751 0.3335 0.6909 0.5522 0.6200 0.6614 0.9120
21 0.7632 0.5908 0.1869 0.8116 0.7333 0.4560 0.4835 0.7753 0.0865 0.3286
22 0.1249 0.7883 0.1968 0.9952 0.5347 0.4430 0.5891 0.4690 0.9150 0.3499
23 0.9509 0.2416 0.5687 0.9704 0.1275 0.4337 0.0802 0.8512 0.8285 0.6816
24 0.3047 0.2111 0.4398 0.6197 0.1255 0.0840 0.8155 0.5149 0.8427 0.8505
25 0.4481 0.5259 0.0698 0.8693 0.0466 0.8320 0.8938 0.2940 0.9170 0.0944
26 0.4181 0.6224 0.4460 0.8794 0.9496 0.8470 0.7221 0.7482 0.7727 0.4208
27 0.3184 0.8117 0.7152 0.6584 0.6259 0.6799 0.6892 0.7112 0.0547 0.4490
28 0.3401 0.4319 0.0547 0.9120 0.9716 0.3194 0.5990 0.9391 0.9754 0.6824
29 0.8052 0.6057 0.9839 0.5489 0.8370 0.6061 0.8981 0.3281 0.1717 0.4825
30 0.7840 0.5313 0.8569 0.6802 0.7889 0.0250 0.3750 0.9000 0.1109 0.1403
31 0.7090 0.4292 0.9482 0.1282 0.9499 0.6498 0.3142 0.2783 0.0928 0.6038
32 0.4704 0.2996 0.4392 0.6758 0.5779 0.9271 0.4617 0.9651 0.7338 0.6889
33 0.5112 0.4090 0.8423 0.4510 0.0785 0.8039 0.5763 0.9675 0.0209 0.8984
34 0.6994 0.7294 0.6438 0.5091 0.7397 0.1954 0.0050 0.9498 0.1800 0.7165
35 0.5646 0.6167 0.9216 0.3667 0.0588 0.5771 0.1406 0.4513 0.3405 0.6177
36 0.1062 0.2994 0.8165 0.4713 0.6953 0.0434 0.6659 0.9865 0.0171 0.1494
37 0.8454 0.9546 0.4266 0.3419 0.7879 0.8589 0.0104 0.8425 0.4340 0.3242
38 0.7697 0.2753 0.8882 0.1683 0.3558 0.5314 0.6455 0.6330 0.3275 0.6251
39 0.5945 0.8478 0.0543 0.1855 0.0291 0.6787 0.9249 0.8378 0.2718 0.6493
40 0.0183 0.5466 0.4700 0.6131 0.5408 0.6029 0.8832 0.8818 0.6836 0.6668
41 0.1035 0.4023 0.6680 0.6104 0.2663 0.0695 0.4614 0.7931 0.1409 0.8702
42 0.4594 0.2053 0.1144 0.6149 0.7996 0.2536 0.5456 0.9692 0.1626 0.5179
43 0.1962 0.1225 0.4092 0.9417 0.2647 0.1758 0.6966 0.9765 0.5197 0.2869
44 0.4398 0.1133 0.1474 0.3441 0.5611 0.3131 0.9505 0.3744 0.8486 0.0985
45 0.6974 0.0284 0.0941 0.0659 0.2542 0.5035 0.3399 0.6510 0.9531 0.0244
46 0.2769 0.0134 0.4791 0.5542 0.6514 0.7688 0.4539 0.9673 0.4047 0.8471
47 0.6141 0.3238 0.4152 0.7964 0.0888 0.1949 0.8640 0.6280 0.3551 0.7824
48 0.3611 0.3299 0.7743 0.1296 0.4178 0.8592 0.7829 0.1917 0.5539 0.0013
49 0.5611 0.5468 0.0939 0.3144 0.4588 0.0335 0.5098 0.7851 0.8458 0.0569
50 0.1925 0.0366 0.8252 0.8454 0.8213 0.9756 0.4695 0.6531 0.4668 0.2165
51 0.1032 0.4966 0.8489 0.3741 0.0208 0.9242 0.7200 0.4328 0.1779 0.4449
52 0.5028 0.7869 0.1418 0.1630 0.8667 0.2575 0.4094 0.9114 0.2282 0.3131
53 0.1956 0.4515 0.0015 0.6526 0.1929 0.7154 0.4678 0.0896 0.9520 0.8036
54 0.5381 0.1419 0.5583 0.2085 0.3671 0.3092 0.0048 0.1076 0.7597 0.1426
55 0.1876 0.7049 0.8949 0.7614 0.3253 0.7219 0.1857 0.0830 0.4511 0.2093
56 0.1617 0.0871 0.3496 0.9578 0.6216 0.0982 0.5653 0.4976 0.6228 0.6098
57 0.6879 0.7922 0.4432 0.9523 0.9778 0.6403 0.6625 0.1386 0.0792 0.5392
58 0.9245 0.4286 0.1109 0.2229 0.7719 0.0637 0.5335 0.8990 0.3833 0.8605
59 0.2065 0.4677 0.6632 0.2226 0.6991 0.0758 0.6639 0.1081 0.7928 0.1713
60 0.1282 0.3299 0.1687 0.7226 0.5604 0.4557 0.5223 0.9807 0.8605 0.0134
61 0.7529 0.6000 0.1404 0.0177 0.7896 0.2222 0.3011 0.8391 0.5260 0.7416
62 0.1384 0.1348 0.3712 0.3009 0.8576 0.3784 0.9518 0.2284 0.7686 0.6503
63 0.4364 0.6128 0.2023 0.5342 0.6028 0.8711 0.6584 0.9166 0.3676 0.5376
64 0.4383 0.7677 0.0034 0.6480 0.8169 0.8175 0.5802 0.6822 0.4747 0.0122
65 0.2226 0.2096 0.6003 0.9336 0.4733 0.6842 0.4656 0.6029 0.5945 0.0236
66 0.8746 0.0288 0.8697 0.1480 0.9583 0.5712 0.6063 0.4100 0.9658 0.6431
67 0.8036 0.5338 0.3974 0.1371 0.7956 0.7795 0.1147 0.4856 0.2419 0.5973
68 0.1161 0.5101 0.8672 0.9617 0.4507 0.0987 0.8943 0.7229 0.5066 0.5003
69 0.7310 0.8391 0.1769 0.9629 0.7689 0.6451 0.8616 0.5678 0.4412 0.0301
You can also pass other options if you want to customize it further:
[13]:
df.disp(precision=1, ncols=5, nrows=10, colheader_justify='left')
0 1 ... 8 9
0 0.8 1.1e-01 ... 0.7 5.0e-01
1 0.2 7.2e-01 ... 0.9 6.6e-01
2 0.8 1.6e-01 ... 0.3 4.4e-01
3 0.5 6.6e-01 ... 0.1 8.5e-01
4 0.6 6.4e-01 ... 0.5 4.9e-01
.. ... ... ... ... ...
65 0.2 2.1e-01 ... 0.6 2.4e-02
66 0.9 2.9e-02 ... 1.0 6.4e-01
67 0.8 5.3e-01 ... 0.2 6.0e-01
68 0.1 5.1e-01 ... 0.5 5.0e-01
69 0.7 8.4e-01 ... 0.4 3.0e-02
[70 rows x 10 columns]
Dataframe indexing#
All the regular pandas
methods (df['mycol']
, df.mycol
, df.loc
, df.iloc
, etc.) work exactly the same. But Sciris gives additional options for indexing. Specifically, getitem
commands (what happens under the hood when you call df[thing]
) will first try the standard pandas getitem
, but then fall back to iloc
if that fails. For example:
[14]:
df = sc.dataframe(
x = [1, 2, 3],
values = [45, 23, 37],
valid = [1, 0, 1]
)
sc.heading('Regular pandas indexing')
print(df['values',1])
sc.heading('Pandas-like iloc indexing')
print(df.iloc[1])
sc.heading('Automatic iloc indexing')
print(df[1]) # Would be a KeyError in regular pandas
———————————————————————
Regular pandas indexing
———————————————————————
23
—————————————————————————
Pandas-like iloc indexing
—————————————————————————
x 2
values 23
valid 0
Name: 1, dtype: int64
———————————————————————
Automatic iloc indexing
———————————————————————
x 2
values 23
valid 0
Name: 1, dtype: int64
Dataframe manipulation#
One quirk of pandas
dataframes is that almost every operation creates a copy rather than modifies the original dataframe in-place (leading to the infamous SettingWithCopyWarning.) This is extremely helpful, and yet, sometimes you do want to modify a dataframe in place. For example, to append a row:
[15]:
# Create the dataframe
df = sc.dataframe(
x = ['a','b','c'],
y = [1, 2, 3],
z = [1, 0, 1],
)
# Define the new row
newrow = ['d', 4, 0]
# Append it in-place
df.appendrow(newrow)
# Show the result
print(df)
x y z
0 a 1 1
1 b 2 0
2 c 3 1
3 d 4 0
That was easy! For reference, here’s the pandas
equivalent (since append
was deprecated):
[16]:
# Convert to a vanilla dataframe
pdf = df.to_pandas()
# Define the new row
newrow = ['e', 5, 1]
# Append it
pdf = pd.concat([pdf, pd.DataFrame([newrow], columns=pdf.columns)])
That’s rather a pain to type, and if you mess up (e.g. type newrow
instead of [newrow]
), in some cases it won’t even fail, just give you the wrong result! Crikey.
Just like how sc.cat()
will take anything vaguely arrayish and turn it into an actual array, sc.dataframe.cat()
will do the same thing:
[17]:
df = sc.dataframe.cat(
sc.dataframe(x=['a','b'], y=[1,2]), # Actual dataframe
dict(x=['c','d'], y=[3,4]), # Dict of data
[['e',5], ['f', 6]], # Or just the data!
)
print(df)
x y
0 a 1
1 b 2
2 c d
3 3 4
4 e 5
5 f 6